Merge remote-tracking branch 'huginn/master' into omniauth

Conflicts:
app/models/agents/basecamp_agent.rb
app/models/user.rb
db/schema.rb
spec/fixtures/agents.yml

Dominik Sander 10 years ago
parent
commit
f07db9c97c
110 changed files with 2901 additions and 963 deletions
  1. 11 5
      app/assets/javascripts/application.js.coffee.erb
  2. 18 2
      app/assets/stylesheets/application.css.scss.erb
  3. 15 0
      app/assets/stylesheets/scenarios.css.scss
  4. 1 1
      lib/assignable_types.rb
  5. 0 3
      app/concerns/email_concern.rb
  6. 13 0
      app/concerns/has_guid.rb
  7. 0 0
      app/concerns/inheritance_tracking.rb
  8. 0 0
      app/concerns/json_serialized_field.rb
  9. 17 13
      app/concerns/liquid_interpolatable.rb
  10. 0 0
      app/concerns/markdown_class_attributes.rb
  11. 53 21
      app/controllers/agents_controller.rb
  12. 20 0
      app/controllers/scenario_imports_controller.rb
  13. 100 0
      app/controllers/scenarios_controller.rb
  14. 6 0
      app/helpers/agent_helper.rb
  15. 1 0
      app/helpers/dot_helper.rb
  16. 10 1
      app/models/agent.rb
  17. 4 4
      app/models/agents/adioso_agent.rb
  18. 1 1
      app/models/agents/basecamp_agent.rb
  19. 7 8
      app/models/agents/data_output_agent.rb
  20. 1 1
      app/models/agents/email_agent.rb
  21. 1 1
      app/models/agents/email_digest_agent.rb
  22. 6 6
      app/models/agents/event_formatting_agent.rb
  23. 4 4
      app/models/agents/ftpsite_agent.rb
  24. 6 6
      app/models/agents/growl_agent.rb
  25. 2 4
      app/models/agents/hipchat_agent.rb
  26. 6 8
      app/models/agents/human_task_agent.rb
  27. 7 7
      app/models/agents/imap_folder_agent.rb
  28. 7 9
      app/models/agents/jabber_agent.rb
  29. 7 7
      app/models/agents/java_script_agent.rb
  30. 8 8
      app/models/agents/jira_agent.rb
  31. 11 11
      app/models/agents/mqtt_agent.rb
  32. 11 13
      app/models/agents/peak_detector_agent.rb
  33. 6 6
      app/models/agents/post_agent.rb
  34. 4 4
      app/models/agents/public_transport_agent.rb
  35. 3 4
      app/models/agents/pushbullet_agent.rb
  36. 30 30
      app/models/agents/pushover_agent.rb
  37. 2 2
      app/models/agents/sentiment_agent.rb
  38. 8 7
      app/models/agents/shell_command_agent.rb
  39. 4 5
      app/models/agents/slack_agent.rb
  40. 1 1
      app/models/agents/stubhub_agent.rb
  41. 6 8
      app/models/agents/translation_agent.rb
  42. 9 8
      app/models/agents/trigger_agent.rb
  43. 9 9
      app/models/agents/twilio_agent.rb
  44. 2 3
      app/models/agents/twitter_publish_agent.rb
  45. 5 5
      app/models/agents/twitter_stream_agent.rb
  46. 5 5
      app/models/agents/twitter_user_agent.rb
  47. 7 7
      app/models/agents/weather_agent.rb
  48. 4 4
      app/models/agents/webhook_agent.rb
  49. 22 22
      app/models/agents/website_agent.rb
  50. 3 3
      app/models/agents/weibo_publish_agent.rb
  51. 3 3
      app/models/agents/weibo_user_agent.rb
  52. 19 0
      app/models/scenario.rb
  53. 256 0
      app/models/scenario_import.rb
  54. 4 0
      app/models/scenario_membership.rb
  55. 2 2
      app/models/user.rb
  56. 12 2
      app/views/agents/_action_menu.html.erb
  57. 14 2
      app/views/agents/_form.html.erb
  58. 75 0
      app/views/agents/_table.html.erb
  59. 1 1
      app/views/agents/agent_views/manual_event_agent/_show.html.erb
  60. 1 1
      app/views/agents/diagram.html.erb
  61. 1 70
      app/views/agents/index.html.erb
  62. 1 1
      app/views/agents/show.html.erb
  63. 2 2
      app/views/events/index.html.erb
  64. 1 0
      app/views/layouts/_navigation.html.erb
  65. 14 10
      app/views/layouts/application.html.erb
  66. 31 0
      app/views/scenario_imports/_step_one.html.erb
  67. 154 0
      app/views/scenario_imports/_step_two.html.erb
  68. 32 0
      app/views/scenario_imports/new.html.erb
  69. 57 0
      app/views/scenarios/_form.html.erb
  70. 21 0
      app/views/scenarios/edit.html.erb
  71. 50 0
      app/views/scenarios/index.html.erb
  72. 21 0
      app/views/scenarios/new.html.erb
  73. 33 0
      app/views/scenarios/share.html.erb
  74. 28 0
      app/views/scenarios/show.html.erb
  75. 12 0
      config/routes.rb
  76. 12 0
      db/migrate/20140509170420_create_scenarios.rb
  77. 10 0
      db/migrate/20140509170443_create_scenario_memberships.rb
  78. 8 0
      db/migrate/20140531232016_add_fields_to_scenarios.rb
  79. 7 0
      db/migrate/20140602014917_add_indices_to_scenarios.rb
  80. 15 0
      db/migrate/20140605032822_add_guid_to_agents.rb
  81. 27 1
      db/schema.rb
  82. 54 0
      lib/agents_exporter.rb
  83. 0 0
      spec/concerns/inheritance_tracking_spec.rb
  84. 103 0
      spec/controllers/agents_controller_spec.rb
  85. 26 0
      spec/controllers/scenario_imports_controller_spec.rb
  86. 152 0
      spec/controllers/scenarios_controller_spec.rb
  87. 10 1
      spec/fixtures/agents.yml
  88. 15 0
      spec/fixtures/scenario_memberships.yml
  89. 13 0
      spec/fixtures/scenarios.yml
  90. 3 1
      spec/fixtures/users.yml
  91. 61 0
      spec/lib/agents_exporter_spec.rb
  92. 28 1
      spec/models/agent_spec.rb
  93. 0 3
      spec/models/agents/data_output_agent_spec.rb
  94. 0 3
      spec/models/agents/hipchat_agent_spec.rb
  95. 0 3
      spec/models/agents/human_task_agent_spec.rb
  96. 0 3
      spec/models/agents/jabber_agent_spec.rb
  97. 0 3
      spec/models/agents/peak_detector_agent_spec.rb
  98. 0 3
      spec/models/agents/pushbullet_agent_spec.rb
  99. 4 3
      spec/models/agents/shell_command_agent_spec.rb
  100. 2 4
      spec/models/agents/slack_agent_spec.rb
  101. 0 4
      spec/models/agents/translation_agent_spec.rb
  102. 0 3
      spec/models/agents/trigger_agent_spec.rb
  103. 411 0
      spec/models/scenario_import_spec.rb
  104. 43 0
      spec/models/scenario_spec.rb
  105. 12 0
      spec/support/shared_examples/has_guid.rb
  106. 2 1
      spec/models/concerns/liquid_interpolatable.rb
  107. 3 3
      spec/models/concerns/working_helpers.rb
  108. 514 489
      vendor/assets/javascripts/jquery.json-editor.js
  109. 37 0
      vendor/assets/stylesheets/jquery.json-editor.css
  110. 0 63
      vendor/assets/stylesheets/jquery.json-editor.css.scss

+ 11 - 5
app/assets/javascripts/application.js.coffee.erb

@@ -9,14 +9,17 @@
9 9
 #= require ./worker-checker
10 10
 #= require_self
11 11
 
12
-window.setupJsonEditor = ($editor = $(".live-json-editor")) ->
12
+window.setupJsonEditor = ($editors = $(".live-json-editor")) ->
13 13
   JSONEditor.prototype.ADD_IMG = '<%= image_path 'json-editor/add.png' %>'
14 14
   JSONEditor.prototype.DELETE_IMG = '<%= image_path 'json-editor/delete.png' %>'
15
-  if $editor.length
15
+  editors = []
16
+  $editors.each ->
17
+    $editor = $(this)
16 18
     jsonEditor = new JSONEditor($editor, $editor.data('width') || 400, $editor.data('height') || 500)
17 19
     jsonEditor.doTruncation true
18 20
     jsonEditor.showFunctionButtons()
19
-    return jsonEditor
21
+    editors.push jsonEditor
22
+  return editors
20 23
 
21 24
 hideSchedule = ->
22 25
   $(".schedule-region select").hide()
@@ -55,12 +58,15 @@ showEventDescriptions = ->
55 58
 
56 59
 $(document).ready ->
57 60
   # JSON Editor
58
-  window.jsonEditor = setupJsonEditor()
61
+  window.jsonEditor = setupJsonEditor()[0]
59 62
 
60 63
   # Flash
61 64
   if $(".flash").length
62 65
     setTimeout((-> $(".flash").slideUp(-> $(".flash").remove())), 5000)
63 66
 
67
+  # Help popovers
68
+  $('.hover-help').popover(trigger: 'hover')
69
+
64 70
   # Agent Navigation
65 71
   $agentNavigate = $('#agent-navigate')
66 72
 
@@ -99,7 +105,7 @@ $(document).ready ->
99 105
         e.preventDefault()
100 106
         $agentNavigate.focus()
101 107
 
102
-# Agent Show
108
+  # Agent Show
103 109
   fetchLogs = (e) ->
104 110
     agentId = $(e.target).closest("[data-agent-id]").data("agent-id")
105 111
     e.preventDefault()

+ 18 - 2
app/assets/stylesheets/application.css.scss.erb

@@ -140,13 +140,29 @@ span.not-applicable:after {
140 140
   opacity: 0.5;
141 141
 }
142 142
 
143
-// Fix JSON Editor
143
+// JSON Editor
144
+
145
+.live-json-editor {
146
+  font-family: "Courier New", Courier, monospace;
147
+}
144 148
 
145 149
 .json-editor blockquote {
146 150
   font-size: 14px;
147 151
 }
148 152
 
149
-// Bootstrappy colour styles
153
+// Position tweeks
154
+
155
+.hover-help {
156
+  top: 2px;
157
+}
158
+
159
+h2 .scenario, a span.label.scenario {
160
+  position: relative;
161
+  top: -2px;
162
+}
163
+
164
+// Bootstrappy color styles
165
+
150 166
 .color-danger {
151 167
   color: #d9534f;
152 168
 }

+ 15 - 0
app/assets/stylesheets/scenarios.css.scss

@@ -0,0 +1,15 @@
1
+.scenario-import {
2
+  .agent-import-list {
3
+    .agent-import {
4
+      margin-bottom: 20px;
5
+
6
+      .instructions {
7
+        margin-bottom: 10px;
8
+      }
9
+
10
+      .current {
11
+        font-weight: bold;
12
+      }
13
+    }
14
+  }
15
+}

+ 1 - 1
lib/assignable_types.rb

@@ -29,7 +29,7 @@ module AssignableTypes
29 29
       const_get(:TYPES).include?(type)
30 30
     end
31 31
 
32
-    def build_for_type(type, user, attributes)
32
+    def build_for_type(type, user, attributes = {})
33 33
       attributes.delete(:type)
34 34
 
35 35
       if valid_type?(type)

+ 0 - 3
app/concerns/email_concern.rb

@@ -31,7 +31,4 @@ module EmailConcern
31 31
   def present_hash(hash, skip_key = nil)
32 32
     hash.to_a.sort_by {|a| a.first.to_s }.map { |k, v| "#{k}: #{v}" unless k.to_s == skip_key.to_s }.compact
33 33
   end
34
-
35
-  module ClassMethods
36
-  end
37 34
 end

+ 13 - 0
app/concerns/has_guid.rb

@@ -0,0 +1,13 @@
1
+module HasGuid
2
+  extend ActiveSupport::Concern
3
+
4
+  included do
5
+    before_save :make_guid
6
+  end
7
+
8
+  protected
9
+
10
+  def make_guid
11
+    self.guid = SecureRandom.hex unless guid.present?
12
+  end
13
+end

lib/inheritance_tracking.rb → app/concerns/inheritance_tracking.rb


lib/json_serialized_field.rb → app/concerns/json_serialized_field.rb


+ 17 - 13
app/concerns/liquid_interpolatable.rb

@@ -1,22 +1,26 @@
1 1
 module LiquidInterpolatable
2 2
   extend ActiveSupport::Concern
3 3
 
4
-  def interpolate_options(options, payload)
5
-    case options.class.to_s
6
-    when 'String'
7
-      interpolate_string(options, payload)
8
-    when 'ActiveSupport::HashWithIndifferentAccess', 'Hash'
9
-      duped_options = options.dup
10
-      duped_options.each do |key, value|
11
-        duped_options[key] = interpolate_options(value, payload)
12
-      end
13
-    when 'Array'
14
-      options.collect do |value|
15
-        interpolate_options(value, payload)
16
-      end
4
+  def interpolate_options(options, payload = {})
5
+    case options
6
+      when String
7
+        interpolate_string(options, payload)
8
+      when ActiveSupport::HashWithIndifferentAccess, Hash
9
+        options.inject(ActiveSupport::HashWithIndifferentAccess.new) { |memo, (key, value)| memo[key] = interpolate_options(value, payload); memo }
10
+      when Array
11
+        options.map { |value| interpolate_options(value, payload) }
12
+      else
13
+        options
17 14
     end
18 15
   end
19 16
 
17
+  def interpolated(payload = {})
18
+    key = [options, payload]
19
+    @interpolated_cache ||= {}
20
+    @interpolated_cache[key] ||= interpolate_options(options, payload)
21
+    @interpolated_cache[key]
22
+  end
23
+
20 24
   def interpolate_string(string, payload)
21 25
     Liquid::Template.parse(string).render!(payload, registers: {agent: self})
22 26
   end

lib/markdown_class_attributes.rb → app/concerns/markdown_class_attributes.rb


+ 53 - 21
app/controllers/agents_controller.rb

@@ -21,12 +21,12 @@ class AgentsController < ApplicationController
21 21
   end
22 22
 
23 23
   def run
24
-    agent = current_user.agents.find(params[:id])
25
-    Agent.async_check(agent.id)
26
-    if params[:return] == "show"
27
-      redirect_to agent_path(agent), notice: "Agent run queued"
28
-    else
29
-      redirect_to agents_path, notice: "Agent run queued"
24
+    @agent = current_user.agents.find(params[:id])
25
+    Agent.async_check(@agent.id)
26
+
27
+    respond_to do |format|
28
+      format.html { redirect_back "Agent run queued for '#{@agent.name}'" }
29
+      format.json { head :ok }
30 30
     end
31 31
   end
32 32
 
@@ -54,12 +54,20 @@ class AgentsController < ApplicationController
54 54
   def remove_events
55 55
     @agent = current_user.agents.find(params[:id])
56 56
     @agent.events.delete_all
57
-    redirect_to agents_path, notice: "All events removed"
57
+
58
+    respond_to do |format|
59
+      format.html { redirect_back "All emitted events removed for '#{@agent.name}'" }
60
+      format.json { head :ok }
61
+    end
58 62
   end
59 63
 
60 64
   def propagate
61
-    details = Agent.receive!
62
-    redirect_to agents_path, notice: "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)"
65
+    details = Agent.receive! # Eventually this should probably be scoped to the current_user.
66
+
67
+    respond_to do |format|
68
+      format.html { redirect_back "Queued propagation calls for #{details[:event_count]} event(s) on #{details[:agent_count]} agent(s)" }
69
+      format.json { head :ok }
70
+    end
63 71
   end
64 72
 
65 73
   def show
@@ -91,7 +99,11 @@ class AgentsController < ApplicationController
91 99
   end
92 100
 
93 101
   def diagram
94
-    @agents = current_user.agents.includes(:receivers)
102
+    @agents = if params[:scenario_id].present?
103
+                current_user.scenarios.find(params[:scenario_id]).agents.includes(:receivers)
104
+              else
105
+                current_user.agents.includes(:receivers)
106
+              end
95 107
   end
96 108
 
97 109
   def create
@@ -100,8 +112,8 @@ class AgentsController < ApplicationController
100 112
                                   params[:agent])
101 113
     respond_to do |format|
102 114
       if @agent.save
103
-        format.html { redirect_to agents_path, notice: 'Your Agent was successfully created.' }
104
-        format.json { render json: @agent, status: :created, location: @agent }
115
+        format.html { redirect_back "'#{@agent.name}' was successfully created." }
116
+        format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
105 117
       else
106 118
         format.html { render action: "new" }
107 119
         format.json { render json: @agent.errors, status: :unprocessable_entity }
@@ -114,14 +126,8 @@ class AgentsController < ApplicationController
114 126
 
115 127
     respond_to do |format|
116 128
       if @agent.update_attributes(params[:agent])
117
-        format.html {
118
-          if params[:return] == "show"
119
-            redirect_to agent_path(@agent), notice: 'Your Agent was successfully updated.'
120
-          else
121
-            redirect_to agents_path, notice: 'Your Agent was successfully updated.'
122
-          end
123
-        }
124
-        format.json { head :no_content }
129
+        format.html { redirect_back "'#{@agent.name}' was successfully updated." }
130
+        format.json { render json: @agent, status: :ok, location: agent_path(@agent) }
125 131
       else
126 132
         format.html { render action: "edit" }
127 133
         format.json { render json: @agent.errors, status: :unprocessable_entity }
@@ -129,13 +135,39 @@ class AgentsController < ApplicationController
129 135
     end
130 136
   end
131 137
 
138
+  def leave_scenario
139
+    @agent = current_user.agents.find(params[:id])
140
+    @scenario = current_user.scenarios.find(params[:scenario_id])
141
+    @agent.scenarios.destroy(@scenario)
142
+
143
+    respond_to do |format|
144
+      format.html { redirect_back "'#{@agent.name}' removed from '#{@scenario.name}'" }
145
+      format.json { head :no_content }
146
+    end
147
+  end
148
+
132 149
   def destroy
133 150
     @agent = current_user.agents.find(params[:id])
134 151
     @agent.destroy
135 152
 
136 153
     respond_to do |format|
137
-      format.html { redirect_to agents_path }
154
+      format.html { redirect_back "'#{@agent.name}' deleted" }
138 155
       format.json { head :no_content }
139 156
     end
140 157
   end
158
+
159
+  protected
160
+
161
+  # Sanitize params[:return] to prevent open redirect attacks, a common security issue.
162
+  def redirect_back(message)
163
+    if params[:return] == "show" && @agent
164
+      path = agent_path(@agent)
165
+    elsif params[:return] =~ /\A#{Regexp::escape scenarios_path}\/\d+\Z/
166
+      path = params[:return]
167
+    else
168
+      path = agents_path
169
+    end
170
+
171
+    redirect_to path, notice: message
172
+  end
141 173
 end

+ 20 - 0
app/controllers/scenario_imports_controller.rb

@@ -0,0 +1,20 @@
1
+class ScenarioImportsController < ApplicationController
2
+  def new
3
+    @scenario_import = ScenarioImport.new(:url => params[:url])
4
+  end
5
+
6
+  def create
7
+    @scenario_import = ScenarioImport.new(params[:scenario_import])
8
+    @scenario_import.set_user(current_user)
9
+
10
+    if @scenario_import.will_request_local?(scenarios_url)
11
+      render :text => 'Sorry, you cannot import a Scenario by URL from your own Huginn server.' and return
12
+    end
13
+
14
+    if @scenario_import.valid? && @scenario_import.should_import? && @scenario_import.import
15
+      redirect_to @scenario_import.scenario, notice: "Import successful!"
16
+    else
17
+      render action: "new"
18
+    end
19
+  end
20
+end

+ 100 - 0
app/controllers/scenarios_controller.rb

@@ -0,0 +1,100 @@
1
+class ScenariosController < ApplicationController
2
+  skip_before_filter :authenticate_user!, :only => :export
3
+
4
+  def index
5
+    @scenarios = current_user.scenarios.page(params[:page])
6
+
7
+    respond_to do |format|
8
+      format.html
9
+      format.json { render json: @scenarios }
10
+    end
11
+  end
12
+
13
+  def new
14
+    @scenario = current_user.scenarios.build
15
+
16
+    respond_to do |format|
17
+      format.html
18
+      format.json { render json: @scenario }
19
+    end
20
+  end
21
+
22
+  def show
23
+    @scenario = current_user.scenarios.find(params[:id])
24
+    @agents = @scenario.agents.preload(:scenarios).page(params[:page])
25
+
26
+    respond_to do |format|
27
+      format.html
28
+      format.json { render json: @scenario }
29
+    end
30
+  end
31
+
32
+  def share
33
+    @scenario = current_user.scenarios.find(params[:id])
34
+
35
+    respond_to do |format|
36
+      format.html
37
+      format.json { render json: @scenario }
38
+    end
39
+  end
40
+
41
+  def export
42
+    @scenario = Scenario.find(params[:id])
43
+    raise ActiveRecord::RecordNotFound unless @scenario.public? || (current_user && current_user.id == @scenario.user_id)
44
+
45
+    @exporter = AgentsExporter.new(:name => @scenario.name,
46
+                                   :description => @scenario.description,
47
+                                   :guid => @scenario.guid,
48
+                                   :source_url => @scenario.public? && export_scenario_url(@scenario),
49
+                                   :agents => @scenario.agents)
50
+    response.headers['Content-Disposition'] = 'attachment; filename="' + @exporter.filename + '"'
51
+    render :json => JSON.pretty_generate(@exporter.as_json)
52
+  end
53
+
54
+  def edit
55
+    @scenario = current_user.scenarios.find(params[:id])
56
+
57
+    respond_to do |format|
58
+      format.html
59
+      format.json { render json: @scenario }
60
+    end
61
+  end
62
+
63
+  def create
64
+    @scenario = current_user.scenarios.build(params[:scenario])
65
+
66
+    respond_to do |format|
67
+      if @scenario.save
68
+        format.html { redirect_to @scenario, notice: 'This Scenario was successfully created.' }
69
+        format.json { render json: @scenario, status: :created, location: @scenario }
70
+      else
71
+        format.html { render action: "new" }
72
+        format.json { render json: @scenario.errors, status: :unprocessable_entity }
73
+      end
74
+    end
75
+  end
76
+
77
+  def update
78
+    @scenario = current_user.scenarios.find(params[:id])
79
+
80
+    respond_to do |format|
81
+      if @scenario.update_attributes(params[:scenario])
82
+        format.html { redirect_to @scenario, notice: 'This Scenario was successfully updated.' }
83
+        format.json { head :no_content }
84
+      else
85
+        format.html { render action: "edit" }
86
+        format.json { render json: @scenario.errors, status: :unprocessable_entity }
87
+      end
88
+    end
89
+  end
90
+
91
+  def destroy
92
+    @scenario = current_user.scenarios.find(params[:id])
93
+    @scenario.destroy
94
+
95
+    respond_to do |format|
96
+      format.html { redirect_to scenarios_path }
97
+      format.json { head :no_content }
98
+    end
99
+  end
100
+end

+ 6 - 0
app/helpers/agent_helper.rb

@@ -6,6 +6,12 @@ module AgentHelper
6 6
     end
7 7
   end
8 8
 
9
+  def scenario_links(agent)
10
+    agent.scenarios.map { |scenario|
11
+      link_to(scenario.name, scenario, class: "label label-info")
12
+    }.join(" ").html_safe
13
+  end
14
+
9 15
   def agent_show_class(agent)
10 16
     agent.short_type.underscore.dasherize
11 17
   end

+ 1 - 0
app/helpers/dot_helper.rb

@@ -35,6 +35,7 @@ module DotHelper
35 35
           dot << '%s;' % disabled_label(agent)
36 36
         end
37 37
         agent.receivers.each do |receiver|
38
+          next unless agents.include?(receiver)
38 39
           dot << "%s->%s;" % [disabled_label(agent), disabled_label(receiver)]
39 40
         end
40 41
       end

+ 10 - 1
app/models/agent.rb

@@ -12,6 +12,8 @@ class Agent < ActiveRecord::Base
12 12
   include JSONSerializedField
13 13
   include RDBMSFunctions
14 14
   include WorkingHelpers
15
+  include LiquidInterpolatable
16
+  include HasGuid
15 17
 
16 18
   markdown_class_attributes :description, :event_description
17 19
 
@@ -22,13 +24,14 @@ class Agent < ActiveRecord::Base
22 24
 
23 25
   EVENT_RETENTION_SCHEDULES = [["Forever", 0], ["1 day", 1], *([2, 3, 4, 5, 7, 14, 21, 30, 45, 90, 180, 365].map {|n| ["#{n} days", n] })]
24 26
 
25
-  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :keep_events_for, :propagate_immediately
27
+  attr_accessible :options, :memory, :name, :type, :schedule, :disabled, :source_ids, :scenario_ids, :keep_events_for, :propagate_immediately
26 28
 
27 29
   json_serialize :options, :memory
28 30
 
29 31
   validates_presence_of :name, :user
30 32
   validates_inclusion_of :keep_events_for, :in => EVENT_RETENTION_SCHEDULES.map(&:last)
31 33
   validate :sources_are_owned
34
+  validate :scenarios_are_owned
32 35
   validate :validate_schedule
33 36
   validate :validate_options
34 37
 
@@ -49,6 +52,8 @@ class Agent < ActiveRecord::Base
49 52
   has_many :links_as_receiver, :dependent => :delete_all, :foreign_key => "receiver_id", :class_name => "Link", :inverse_of => :receiver
50 53
   has_many :sources, :through => :links_as_receiver, :class_name => "Agent", :inverse_of => :receivers
51 54
   has_many :receivers, :through => :links_as_source, :class_name => "Agent", :inverse_of => :sources
55
+  has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :agent
56
+  has_many :scenarios, :through => :scenario_memberships, :inverse_of => :agents
52 57
 
53 58
   scope :of_type, lambda { |type|
54 59
     type = case type
@@ -207,6 +212,10 @@ class Agent < ActiveRecord::Base
207 212
     errors.add(:sources, "must be owned by you") unless sources.all? {|s| s.user == user }
208 213
   end
209 214
   
215
+  def scenarios_are_owned
216
+    errors.add(:scenarios, "must be owned by you") unless scenarios.all? {|s| s.user == user }
217
+  end
218
+
210 219
   def validate_schedule
211 220
     unless cannot_be_scheduled?
212 221
       errors.add(:schedule, "is not a valid schedule") unless SCHEDULES.include?(schedule.to_s)

+ 4 - 4
app/models/agents/adioso_agent.rb

@@ -54,9 +54,9 @@ module Agents
54 54
     end
55 55
 
56 56
     def check
57
-      auth_options = {:basic_auth => {:username =>options[:username], :password=>options['password']}}
58
-      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?q=#{URI.encode(options['from'])}+to+#{URI.encode(options['to'])}", auth_options
59
-      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(options['end_date'])}\\3#{date_to_unix_epoch(options['start_date'])}"
57
+      auth_options = {:basic_auth => {:username =>interpolated[:username], :password=>interpolated['password']}}
58
+      parse_response = HTTParty.get "http://api.adioso.com/v2/search/parse?q=#{URI.encode(interpolated['from'])}+to+#{URI.encode(interpolated['to'])}", auth_options
59
+      fare_request = parse_response["search_url"].gsub /(end=)(\d*)([^\d]*)(\d*)/, "\\1#{date_to_unix_epoch(interpolated['end_date'])}\\3#{date_to_unix_epoch(interpolated['start_date'])}"
60 60
       fare = HTTParty.get fare_request, auth_options
61 61
 
62 62
 			if fare["warnings"]
@@ -64,7 +64,7 @@ module Agents
64 64
 			else
65 65
 				event = fare["results"].min {|a,b| a["cost"] <=> b["cost"]}
66 66
 				event["date"]  = Time.at(event["date"]).to_date.httpdate[0..15]
67
-				event["route"] = "#{options['from']} to #{options['to']}"
67
+				event["route"] = "#{interpolated['from']} to #{interpolated['to']}"
68 68
 				create_event :payload => event
69 69
 			end
70 70
     end

+ 1 - 1
app/models/agents/basecamp_agent.rb

@@ -72,7 +72,7 @@ module Agents
72 72
 
73 73
   private
74 74
     def request_url
75
-      "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(options[:project_id].to_s)}/events.json"
75
+      "https://basecamp.com/#{URI.encode(self.service.options[:user_id].to_s)}/api/v1/projects/#{URI.encode(interpolated[:project_id].to_s)}/events.json"
76 76
     end
77 77
 
78 78
     def request_options

+ 7 - 8
app/models/agents/data_output_agent.rb

@@ -1,7 +1,5 @@
1 1
 module Agents
2 2
   class DataOutputAgent < Agent
3
-    include LiquidInterpolatable
4
-
5 3
     cannot_be_scheduled!
6 4
 
7 5
     description  do
@@ -52,6 +50,7 @@ module Agents
52 50
       unless options['secrets'].is_a?(Array) && options['secrets'].length > 0
53 51
         errors.add(:base, "Please specify one or more secrets for 'authenticating' incoming feed requests")
54 52
       end
53
+
55 54
       unless options['expected_receive_period_in_days'].present? && options['expected_receive_period_in_days'].to_i > 0
56 55
         errors.add(:base, "Please provide 'expected_receive_period_in_days' to indicate how many days can pass before this Agent is considered to be not working")
57 56
       end
@@ -62,27 +61,27 @@ module Agents
62 61
     end
63 62
 
64 63
     def events_to_show
65
-      (options['events_to_show'].presence || 40).to_i
64
+      (interpolated['events_to_show'].presence || 40).to_i
66 65
     end
67 66
 
68 67
     def feed_ttl
69
-      (options['ttl'].presence || 60).to_i
68
+      (interpolated['ttl'].presence || 60).to_i
70 69
     end
71 70
 
72 71
     def feed_title
73
-      options['template']['title'].presence || "#{name} Event Feed"
72
+      interpolated['template']['title'].presence || "#{name} Event Feed"
74 73
     end
75 74
 
76 75
     def feed_link
77
-      options['template']['link'].presence || "https://#{ENV['DOMAIN']}"
76
+      interpolated['template']['link'].presence || "https://#{ENV['DOMAIN']}"
78 77
     end
79 78
 
80 79
     def feed_description
81
-      options['template']['description'].presence || "A feed of Events received by the '#{name}' Huginn Agent"
80
+      interpolated['template']['description'].presence || "A feed of Events received by the '#{name}' Huginn Agent"
82 81
     end
83 82
 
84 83
     def receive_web_request(params, method, format)
85
-      if options['secrets'].include?(params['secret'])
84
+      if interpolated['secrets'].include?(params['secret'])
86 85
         items = received_events.order('id desc').limit(events_to_show).map do |event|
87 86
           interpolated = interpolate_options(options['template']['item'], event.payload)
88 87
           interpolated['guid'] = event.id

+ 1 - 1
app/models/agents/email_agent.rb

@@ -25,7 +25,7 @@ module Agents
25 25
     def receive(incoming_events)
26 26
       incoming_events.each do |event|
27 27
         log "Sending digest mail to #{user.email} with event #{event.id}"
28
-        SystemMailer.delay.send_message(:to => user.email, :subject => options['subject'], :headline => options['headline'], :groups => [present(event.payload)])
28
+        SystemMailer.delay.send_message(:to => user.email, :subject => interpolated(event.payload)['subject'], :headline => interpolated(event.payload)['headline'], :groups => [present(event.payload)])
29 29
       end
30 30
     end
31 31
   end

+ 1 - 1
app/models/agents/email_digest_agent.rb

@@ -37,7 +37,7 @@ module Agents
37 37
         ids = self.memory['events'].join(",")
38 38
         groups = self.memory['queue'].map { |payload| present(payload) }
39 39
         log "Sending digest mail to #{user.email} with events [#{ids}]"
40
-        SystemMailer.delay.send_message(:to => user.email, :subject => options['subject'], :headline => options['headline'], :groups => groups)
40
+        SystemMailer.delay.send_message(:to => user.email, :subject => interpolated['subject'], :headline => interpolated['headline'], :groups => groups)
41 41
         self.memory['queue'] = []
42 42
         self.memory['events'] = []
43 43
       end

+ 6 - 6
app/models/agents/event_formatting_agent.rb

@@ -1,6 +1,5 @@
1 1
 module Agents
2 2
   class EventFormattingAgent < Agent
3
-    include LiquidInterpolatable
4 3
     cannot_be_scheduled!
5 4
 
6 5
     description <<-MD
@@ -81,7 +80,7 @@ module Agents
81 80
     after_save :clear_matchers
82 81
 
83 82
     def validate_options
84
-      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? and options['mode'].present? and options['skip_agent'].present? and options['skip_created_at'].present?
83
+      errors.add(:base, "instructions, mode, skip_agent, and skip_created_at all need to be present.") unless options['instructions'].present? && options['mode'].present? && options['skip_agent'].present? && options['skip_created_at'].present?
85 84
 
86 85
       validate_matchers
87 86
     end
@@ -105,11 +104,12 @@ module Agents
105 104
 
106 105
     def receive(incoming_events)
107 106
       incoming_events.each do |event|
108
-        formatted_event = options['mode'].to_s == "merge" ? event.payload.dup : {}
109 107
         payload = perform_matching(event.payload)
110
-        formatted_event.merge! interpolate_options(options['instructions'], payload)
111
-        formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless options['skip_agent'].to_s == "true"
112
-        formatted_event['created_at'] = event.created_at unless options['skip_created_at'].to_s == "true"
108
+        opts = interpolated(payload)
109
+        formatted_event = opts['mode'].to_s == "merge" ? event.payload.dup : {}
110
+        formatted_event.merge! opts['instructions']
111
+        formatted_event['agent'] = Agent.find(event.agent_id).type.slice!(8..-1) unless opts['skip_agent'].to_s == "true"
112
+        formatted_event['created_at'] = event.created_at unless opts['skip_created_at'].to_s == "true"
113 113
         create_event :payload => formatted_event
114 114
       end
115 115
     end

+ 4 - 4
app/models/agents/ftpsite_agent.rb

@@ -29,7 +29,7 @@ module Agents
29 29
     MD
30 30
 
31 31
     def working?
32
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
32
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
33 33
     end
34 34
 
35 35
     def default_options
@@ -90,10 +90,10 @@ module Agents
90 90
     end
91 91
 
92 92
     def each_entry
93
-      patterns = options['patterns']
93
+      patterns = interpolated['patterns']
94 94
 
95 95
       after =
96
-        if str = options['after']
96
+        if str = interpolated['after']
97 97
           Time.parse(str)
98 98
         else
99 99
           Time.at(0)
@@ -174,7 +174,7 @@ module Agents
174 174
     end
175 175
 
176 176
     def base_uri
177
-      @base_uri ||= URI(options['url'])
177
+      @base_uri ||= URI(interpolated['url'])
178 178
     end
179 179
 
180 180
     def saving_entries

+ 6 - 6
app/models/agents/growl_agent.rb

@@ -26,7 +26,7 @@ module Agents
26 26
     end
27 27
     
28 28
     def working?
29
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
29
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
30 30
     end
31 31
 
32 32
     def validate_options
@@ -36,13 +36,13 @@ module Agents
36 36
     end
37 37
     
38 38
     def register_growl
39
-      @growler = Growl.new options['growl_server'], options['growl_app_name'], "GNTP"
40
-      @growler.password = options['growl_password']
41
-      @growler.add_notification options['growl_notification_name']
39
+      @growler = Growl.new interpolated['growl_server'], interpolated['growl_app_name'], "GNTP"
40
+      @growler.password = interpolated['growl_password']
41
+      @growler.add_notification interpolated['growl_notification_name']
42 42
     end
43 43
     
44 44
     def notify_growl(subject, message)
45
-      @growler.notify(options['growl_notification_name'],subject,message)
45
+      @growler.notify(interpolated['growl_notification_name'], subject, message)
46 46
     end
47 47
 
48 48
     def receive(incoming_events)
@@ -51,7 +51,7 @@ module Agents
51 51
         message = (event.payload['message'] || event.payload['text']).to_s
52 52
         subject = event.payload['subject'].to_s
53 53
         if message.present? && subject.present?
54
-          log "Sending Growl notification '#{subject}': '#{message}' to #{options['growl_server']} with event #{event.id}"
54
+          log "Sending Growl notification '#{subject}': '#{message}' to #{interpolated(event.payload)['growl_server']} with event #{event.id}"
55 55
           notify_growl(subject,message)
56 56
         else
57 57
           log "Event #{event.id} not sent, message and subject expected"

+ 2 - 4
app/models/agents/hipchat_agent.rb

@@ -1,7 +1,5 @@
1 1
 module Agents
2 2
   class HipchatAgent < Agent
3
-    include LiquidInterpolatable
4
-
5 3
     cannot_be_scheduled!
6 4
     cannot_create_events!
7 5
 
@@ -42,9 +40,9 @@ module Agents
42 40
     end
43 41
 
44 42
     def receive(incoming_events)
45
-      client = HipChat::Client.new(options[:auth_token])
43
+      client = HipChat::Client.new(interpolated[:auth_token])
46 44
       incoming_events.each do |event|
47
-        mo = interpolate_options options, event.payload
45
+        mo = interpolated(event.payload)
48 46
         client[mo[:room_name]].send(mo[:username], mo[:message], :notify => mo[:notify].to_s == 'true' ? 1 : 0, :color => mo[:color])
49 47
       end
50 48
     end

+ 6 - 8
app/models/agents/human_task_agent.rb

@@ -2,8 +2,6 @@ require 'rturk'
2 2
 
3 3
 module Agents
4 4
   class HumanTaskAgent < Agent
5
-    include LiquidInterpolatable
6
-
7 5
     default_schedule "every_10m"
8 6
 
9 7
     description <<-MD
@@ -204,20 +202,20 @@ module Agents
204 202
     end
205 203
 
206 204
     def working?
207
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
205
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
208 206
     end
209 207
 
210 208
     def check
211 209
       review_hits
212 210
 
213
-      if options['trigger_on'] == "schedule" && (memory['last_schedule'] || 0) <= Time.now.to_i - options['submission_period'].to_i * 60 * 60
211
+      if interpolated['trigger_on'] == "schedule" && (memory['last_schedule'] || 0) <= Time.now.to_i - interpolated['submission_period'].to_i * 60 * 60
214 212
         memory['last_schedule'] = Time.now.to_i
215 213
         create_basic_hit
216 214
       end
217 215
     end
218 216
 
219 217
     def receive(incoming_events)
220
-      if options['trigger_on'] == "event"
218
+      if interpolated['trigger_on'] == "event"
221 219
         incoming_events.each do |event|
222 220
           create_basic_hit event
223 221
         end
@@ -227,11 +225,11 @@ module Agents
227 225
     protected
228 226
 
229 227
     def take_majority?
230
-      options['combination_mode'] == "take_majority" || options['take_majority'] == "true"
228
+      interpolated['combination_mode'] == "take_majority" || interpolated['take_majority'] == "true"
231 229
     end
232 230
 
233 231
     def create_poll?
234
-      options['combination_mode'] == "poll"
232
+      interpolated['combination_mode'] == "poll"
235 233
     end
236 234
 
237 235
     def event_for_hit(hit_id)
@@ -367,7 +365,7 @@ module Agents
367 365
     end
368 366
 
369 367
     def all_questions_are_numeric?
370
-      options['hit']['questions'].all? do |question|
368
+      interpolated['hit']['questions'].all? do |question|
371 369
         question['selections'].all? do |selection|
372 370
           selection['key'] == selection['key'].to_f.to_s || selection['key'] == selection['key'].to_i.to_s
373 371
         end

+ 7 - 7
app/models/agents/imap_folder_agent.rb

@@ -111,7 +111,7 @@ module Agents
111 111
     }
112 112
 
113 113
     def working?
114
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
114
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
115 115
     end
116 116
 
117 117
     def default_options
@@ -240,7 +240,7 @@ module Agents
240 240
         matched_part = nil
241 241
         matches = {}
242 242
 
243
-        options['conditions'].all? { |key, value|
243
+        interpolated['conditions'].all? { |key, value|
244 244
           case key
245 245
           when 'subject'
246 246
             value.present? or next true
@@ -308,7 +308,7 @@ module Agents
308 308
           notified << mail.message_id if mail.message_id
309 309
         end
310 310
 
311
-        if options['mark_as_read']
311
+        if interpolated['mark_as_read']
312 312
           log 'Marking as read'
313 313
           mail.mark_as_read
314 314
         end
@@ -322,14 +322,14 @@ module Agents
322 322
     end
323 323
 
324 324
     def each_unread_mail
325
-      host, port, ssl, username = options.values_at(:host, :port, :ssl, :username)
325
+      host, port, ssl, username = interpolated.values_at(:host, :port, :ssl, :username)
326 326
 
327 327
       log "Connecting to #{host}#{':%d' % port if port}#{' via SSL' if ssl}"
328 328
       Client.open(host, Integer(port), ssl) { |imap|
329 329
         log "Logging in as #{username}"
330
-        imap.login(username, options[:password])
330
+        imap.login(username, interpolated[:password])
331 331
 
332
-        options['folders'].each { |folder|
332
+        interpolated['folders'].each { |folder|
333 333
           log "Selecting the folder: %s" % folder
334 334
 
335 335
           imap.select(folder)
@@ -351,7 +351,7 @@ module Agents
351 351
     end
352 352
 
353 353
     def mime_types
354
-      options['mime_types'] || %w[text/plain text/enriched text/html]
354
+      interpolated['mime_types'] || %w[text/plain text/enriched text/html]
355 355
     end
356 356
 
357 357
     private

+ 7 - 9
app/models/agents/jabber_agent.rb

@@ -1,7 +1,5 @@
1 1
 module Agents
2 2
   class JabberAgent < Agent
3
-    include LiquidInterpolatable
4
-
5 3
     cannot_be_scheduled!
6 4
     cannot_create_events!
7 5
 
@@ -30,12 +28,12 @@ module Agents
30 28
     end
31 29
 
32 30
     def working?
33
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
31
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
34 32
     end
35 33
 
36 34
     def receive(incoming_events)
37 35
       incoming_events.each do |event|
38
-        log "Sending IM to #{options['jabber_receiver']} with event #{event.id}"
36
+        log "Sending IM to #{interpolated['jabber_receiver']} with event #{event.id}"
39 37
         deliver body(event)
40 38
       end
41 39
     end
@@ -45,15 +43,15 @@ module Agents
45 43
     end
46 44
 
47 45
     def deliver(text)
48
-      client.send Jabber::Message::new(options['jabber_receiver'], text).set_type(:chat)
46
+      client.send Jabber::Message::new(interpolated['jabber_receiver'], text).set_type(:chat)
49 47
     end
50 48
 
51 49
     private
52 50
 
53 51
     def client
54
-      Jabber::Client.new(Jabber::JID::new(options['jabber_sender'])).tap do |sender|
55
-        sender.connect(options['jabber_server'], (options['jabber_port'] || '5222'))
56
-        sender.auth(options['jabber_password'])
52
+      Jabber::Client.new(Jabber::JID::new(interpolated['jabber_sender'])).tap do |sender|
53
+        sender.connect(interpolated['jabber_server'], interpolated['jabber_port'] || '5222')
54
+        sender.auth interpolated['jabber_password']
57 55
       end
58 56
     end
59 57
 
@@ -62,7 +60,7 @@ module Agents
62 60
     end
63 61
 
64 62
     def body(event)
65
-      interpolate_string(options['message'], event.payload)
63
+      interpolated(event.payload)['message']
66 64
     end
67 65
   end
68 66
 end

+ 7 - 7
app/models/agents/java_script_agent.rb

@@ -35,12 +35,12 @@ module Agents
35 35
     def working?
36 36
       return false if recent_error_logs?
37 37
 
38
-      if options['expected_update_period_in_days'].present?
39
-        return false unless event_created_within?(options['expected_update_period_in_days'])
38
+      if interpolated['expected_update_period_in_days'].present?
39
+        return false unless event_created_within?(interpolated['expected_update_period_in_days'])
40 40
       end
41 41
 
42
-      if options['expected_receive_period_in_days'].present?
43
-        return false unless last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago
42
+      if interpolated['expected_receive_period_in_days'].present?
43
+        return false unless last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago
44 44
       end
45 45
 
46 46
       true
@@ -92,7 +92,7 @@ module Agents
92 92
 
93 93
       context["doCreateEvent"] = lambda { |a, y| create_event(payload: clean_nans(JSON.parse(y))).payload.to_json }
94 94
       context["getIncomingEvents"] = lambda { |a| incoming_events.to_json }
95
-      context["getOptions"] = lambda { |a, x| options.to_json }
95
+      context["getOptions"] = lambda { |a, x| interpolated.to_json }
96 96
       context["doLog"] = lambda { |a, x| log x }
97 97
       context["doError"] = lambda { |a, x| error x }
98 98
       context["getMemory"] = lambda do |a, x, y|
@@ -112,12 +112,12 @@ module Agents
112 112
       if cred
113 113
         credential(cred) || 'Agent.check = function() { this.error("Unable to find credential"); };'
114 114
       else
115
-        options['code']
115
+        interpolated['code']
116 116
       end
117 117
     end
118 118
 
119 119
     def credential_referenced_by_code
120
-      options['code'] =~ /\Acredential:(.*)\Z/ && $1
120
+      interpolated['code'] =~ /\Acredential:(.*)\Z/ && $1
121 121
     end
122 122
 
123 123
     def setup_javascript

+ 8 - 8
app/models/agents/jira_agent.rb

@@ -56,7 +56,7 @@ module Agents
56 56
     end
57 57
 
58 58
     def working?
59
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
59
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
60 60
     end
61 61
 
62 62
     def check
@@ -81,14 +81,14 @@ module Agents
81 81
 
82 82
   private
83 83
     def request_url(jql, start_at)
84
-      "#{options[:jira_url]}/rest/api/2/search?jql=#{CGI::escape(jql)}&fields=*all&startAt=#{start_at}"
84
+      "#{interpolated[:jira_url]}/rest/api/2/search?jql=#{CGI::escape(jql)}&fields=*all&startAt=#{start_at}"
85 85
     end
86 86
 
87 87
     def request_options
88 88
       ropts = {:headers => {"User-Agent" => "Huginn (https://github.com/cantino/huginn)"}}
89 89
 
90
-      if !options[:username].empty?
91
-        ropts = ropts.merge({:basic_auth => {:username =>options[:username], :password=>options[:password]}})
90
+      if !interpolated[:username].empty?
91
+        ropts = ropts.merge({:basic_auth => {:username =>interpolated[:username], :password=>interpolated[:password]}})
92 92
       end
93 93
 
94 94
       ropts
@@ -121,10 +121,10 @@ module Agents
121 121
 
122 122
       jql = ""
123 123
 
124
-      if !options[:jql].empty? && since
125
-        jql = "(#{options[:jql]}) and updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'"
124
+      if !interpolated[:jql].empty? && since
125
+        jql = "(#{interpolated[:jql]}) and updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'"
126 126
       else
127
-        jql = options[:jql] if !options[:jql].empty?
127
+        jql = interpolated[:jql] if !interpolated[:jql].empty?
128 128
         jql = "updated >= '#{since.strftime('%Y-%m-%d %H:%M')}'" if since
129 129
       end
130 130
 
@@ -142,7 +142,7 @@ module Agents
142 142
           raise RuntimeError.new("There is no progress while fetching issues")
143 143
         end
144 144
 
145
-        if Time.now > start_time + options['timeout'].to_i * 60
145
+        if Time.now > start_time + interpolated['timeout'].to_i * 60
146 146
           raise RuntimeError.new("Timeout exceeded while fetching issues")
147 147
         end
148 148
 

+ 11 - 11
app/models/agents/mqtt_agent.rb

@@ -68,13 +68,13 @@ module Agents
68 68
 
69 69
     def validate_options
70 70
       unless options['uri'].present? &&
71
-        options['topic'].present?
71
+             options['topic'].present?
72 72
         errors.add(:base, "topic and uri are required")
73 73
       end
74 74
     end
75 75
 
76 76
     def working?
77
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
77
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
78 78
     end
79 79
 
80 80
     def default_options
@@ -91,13 +91,13 @@ module Agents
91 91
     end
92 92
 
93 93
     def mqtt_client
94
-      @client ||= MQTT::Client.new(options['uri'])
94
+      @client ||= MQTT::Client.new(interpolated['uri'])
95 95
 
96
-      if options['ssl']
97
-        @client.ssl = options['ssl'].to_sym
98
-        @client.ca_file = options['ca_file']
99
-        @client.cert_file = options['cert_file']
100
-        @client.key_file = options['key_file']
96
+      if interpolated['ssl']
97
+        @client.ssl = interpolated['ssl'].to_sym
98
+        @client.ca_file = interpolated['ca_file']
99
+        @client.cert_file = interpolated['cert_file']
100
+        @client.key_file = interpolated['key_file']
101 101
       end
102 102
 
103 103
       @client
@@ -106,7 +106,7 @@ module Agents
106 106
     def receive(incoming_events)
107 107
       mqtt_client.connect do |c|
108 108
         incoming_events.each do |event|
109
-          c.publish(options['topic'], payload)
109
+          c.publish(interpolated(event.payload)['topic'], event.payload)
110 110
         end
111 111
 
112 112
         c.disconnect
@@ -117,8 +117,8 @@ module Agents
117 117
     def check
118 118
       mqtt_client.connect do |c|
119 119
 
120
-        Timeout::timeout((options['max_read_time'].presence || 15).to_i) {
121
-          c.get(options['topic']) do |topic, message|
120
+        Timeout::timeout((interpolated['max_read_time'].presence || 15).to_i) {
121
+          c.get(interpolated['topic']) do |topic, message|
122 122
 
123 123
             # A lot of services generate JSON. Try that first
124 124
             payload = JSON.parse(message) rescue message

+ 11 - 13
app/models/agents/peak_detector_agent.rb

@@ -2,8 +2,6 @@ require 'pp'
2 2
 
3 3
 module Agents
4 4
   class PeakDetectorAgent < Agent
5
-    include LiquidInterpolatable
6
-
7 5
     cannot_be_scheduled!
8 6
 
9 7
     description <<-MD
@@ -45,7 +43,7 @@ module Agents
45 43
     end
46 44
 
47 45
     def working?
48
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
46
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
49 47
     end
50 48
 
51 49
     def receive(incoming_events)
@@ -69,7 +67,7 @@ module Agents
69 67
         if newest_value > average_value + std_multiple * standard_deviation
70 68
           memory['peaks'][group] << newest_time
71 69
           memory['peaks'][group].reject! { |p| p <= newest_time - window_duration }
72
-          create_event :payload => { 'message' => interpolate_string(options['message'], event.payload), 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
70
+          create_event :payload => { 'message' => interpolated(event.payload)['message'], 'peak' => newest_value, 'peak_time' => newest_time, 'grouped_by' => group.to_s }
73 71
         end
74 72
       end
75 73
     end
@@ -94,33 +92,33 @@ module Agents
94 92
     end
95 93
 
96 94
     def window_duration
97
-      if options['window_duration'].present? # The older option
98
-        options['window_duration'].to_i
95
+      if interpolated['window_duration'].present? # The older option
96
+        interpolated['window_duration'].to_i
99 97
       else
100
-        (options['window_duration_in_days'] || 14).to_f.days
98
+        (interpolated['window_duration_in_days'] || 14).to_f.days
101 99
       end
102 100
     end
103 101
 
104 102
     def std_multiple
105
-      (options['std_multiple'] || 3).to_f
103
+      (interpolated['std_multiple'] || 3).to_f
106 104
     end
107 105
 
108 106
     def peak_spacing
109
-      if options['peak_spacing'].present? # The older option
110
-        options['peak_spacing'].to_i
107
+      if interpolated['peak_spacing'].present? # The older option
108
+        interpolated['peak_spacing'].to_i
111 109
       else
112
-        (options['min_peak_spacing_in_days'] || 2).to_f.days
110
+        (interpolated['min_peak_spacing_in_days'] || 2).to_f.days
113 111
       end
114 112
     end
115 113
 
116 114
     def group_for(event)
117
-      ((options['group_by_path'].present? && Utils.value_at(event.payload, options['group_by_path'])) || 'no_group')
115
+      ((interpolated['group_by_path'].present? && Utils.value_at(event.payload, interpolated['group_by_path'])) || 'no_group')
118 116
     end
119 117
 
120 118
     def remember(group, event)
121 119
       memory['data'] ||= {}
122 120
       memory['data'][group] ||= []
123
-      memory['data'][group] << [ Utils.value_at(event.payload, options['value_path']), event.created_at.to_i ]
121
+      memory['data'][group] << [ Utils.value_at(event.payload, interpolated['value_path']), event.created_at.to_i ]
124 122
       cleanup group
125 123
     end
126 124
 

+ 6 - 6
app/models/agents/post_agent.rb

@@ -27,15 +27,15 @@ module Agents
27 27
     end
28 28
 
29 29
     def working?
30
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
30
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
31 31
     end
32 32
 
33 33
     def method
34
-      (options['method'].presence || 'post').to_s.downcase
34
+      (interpolated['method'].presence || 'post').to_s.downcase
35 35
     end
36 36
 
37 37
     def headers
38
-      options['headers'].presence || {}
38
+      interpolated['headers'].presence || {}
39 39
     end
40 40
 
41 41
     def validate_options
@@ -58,16 +58,16 @@ module Agents
58 58
 
59 59
     def receive(incoming_events)
60 60
       incoming_events.each do |event|
61
-        handle (options['payload'].presence || {}).merge(event.payload)
61
+        handle (interpolated(event.payload)['payload'].presence || {}).merge(event.payload)
62 62
       end
63 63
     end
64 64
 
65 65
     def check
66
-      handle options['payload'].presence || {}
66
+      handle interpolated['payload'].presence || {}
67 67
     end
68 68
 
69 69
     def generate_uri(params = nil)
70
-      uri = URI options[:post_url]
70
+      uri = URI interpolated[:post_url]
71 71
       uri.query = URI.encode_www_form(Hash[URI.decode_www_form(uri.query || '')].merge(params)) if params
72 72
       uri
73 73
     end

+ 4 - 4
app/models/agents/public_transport_agent.rb

@@ -48,12 +48,12 @@ module Agents
48 48
     MD
49 49
 
50 50
     def check_url
51
-      stop_query = URI.encode(options["stops"].collect{|a| "&stops=#{a}"}.join)
52
-      "http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=#{options["agency"]}#{stop_query}"
51
+      stop_query = URI.encode(interpolated["stops"].collect{|a| "&stops=#{a}"}.join)
52
+      "http://webservices.nextbus.com/service/publicXMLFeed?command=predictionsForMultiStops&a=#{interpolated["agency"]}#{stop_query}"
53 53
     end
54 54
 
55 55
     def stops
56
-      options["stops"].collect{|a| a.split("|").last}
56
+      interpolated["stops"].collect{|a| a.split("|").last}
57 57
     end
58 58
 
59 59
     def check
@@ -65,7 +65,7 @@ module Agents
65 65
         predictions.each do |pr|
66 66
           parent = pr.parent.parent
67 67
           vals = {"routeTitle" => parent["routeTitle"], "stopTag" => parent["stopTag"]}
68
-          if pr["minutes"] && pr["minutes"].to_i < options["alert_window_in_minutes"].to_i
68
+          if pr["minutes"] && pr["minutes"].to_i < interpolated["alert_window_in_minutes"].to_i
69 69
             vals = vals.merge Hash.from_xml(pr.to_xml)
70 70
             if not_already_in_memory?(vals)
71 71
               create_event(:payload => vals)

+ 3 - 4
app/models/agents/pushbullet_agent.rb

@@ -1,7 +1,5 @@
1 1
 module Agents
2 2
   class PushbulletAgent < Agent
3
-    include LiquidInterpolatable
4
-
5 3
     cannot_be_scheduled!
6 4
     cannot_create_events!
7 5
 
@@ -49,10 +47,11 @@ module Agents
49 47
     end
50 48
 
51 49
     private
50
+
52 51
     def query_options(event)
53
-      mo = interpolate_options options, event.payload
52
+      mo = interpolated(event.payload)
54 53
       {
55
-        :basic_auth => {:username =>mo[:api_key], :password=>''},
54
+        :basic_auth => {:username => mo[:api_key], :password => ''},
56 55
         :body => {:device_iden => mo[:device_id], :title => mo[:title], :body => mo[:body], :type => 'note'}
57 56
       }
58 57
     end

+ 30 - 30
app/models/agents/pushover_agent.rb

@@ -19,13 +19,13 @@ module Agents
19 19
       Your event can provide any of the following optional parameters or you can provide defaults:
20 20
 
21 21
       * `device` - your user's device name to send the message directly to that device, rather than all of the user's devices
22
-      * `title` or `subject` - your notifications's title
22
+      * `title` or `subject` - your notification's title
23 23
       * `url` - a supplementary URL to show with your message - `512` Character Limit
24 24
       * `url_title` - a title for your supplementary URL, otherwise just the URL is shown - `100` Character Limit
25 25
       * `priority` - send as `-1` to always send as a quiet notification, `0` is default, `1` to display as high-priority and bypass the user's quiet hours, or `2` for emergency priority: [Please read Pushover Docs on Emergency Priority](https://pushover.net/api#priority)
26 26
       * `sound` - the name of one of the sounds supported by device clients to override the user's default sound choice. [See PushOver docs for sound options.](https://pushover.net/api#sounds)
27
-      * `retry` - Requred for emergency priority - Specifies how often (in seconds) the Pushover servers will send the same notification to the user. Minimum value: `30`
28
-      * `expire` - Requred for emergency priority - Specifies how many seconds your notification will continue to be retried for (every retry seconds). Maximum value: `86400`
27
+      * `retry` - Required for emergency priority - Specifies how often (in seconds) the Pushover servers will send the same notification to the user. Minimum value: `30`
28
+      * `expire` - Required for emergency priority - Specifies how many seconds your notification will continue to be retried for (every retry seconds). Maximum value: `86400`
29 29
 
30 30
       Your event can also pass along a timestamp parameter:
31 31
 
@@ -42,10 +42,10 @@ module Agents
42 42
         'title' => '',
43 43
         'url' => '',
44 44
         'url_title' => '',
45
-        'priority' => 0,
45
+        'priority' => '0',
46 46
         'sound' => 'pushover',
47
-        'retry' => 0,
48
-        'expire' => 0,
47
+        'retry' => '0',
48
+        'expire' => '0',
49 49
         'expected_receive_period_in_days' => '1'
50 50
       }
51 51
     end
@@ -58,50 +58,50 @@ module Agents
58 58
 
59 59
     def receive(incoming_events)
60 60
       incoming_events.each do |event|
61
-        message = (event.payload['message'].presence  || event.payload['text'].presence  || options['message']).to_s
61
+        payload_interpolated = interpolated(event.payload)
62
+        message = (event.payload['message'].presence || event.payload['text'].presence || payload_interpolated['message']).to_s
62 63
         if message.present?
63
-            post_params = {
64
-              'token' => options['token'],
65
-              'user' => options['user'],
66
-              'message' => message
67
-            }
64
+          post_params = {
65
+            'token' => payload_interpolated['token'],
66
+            'user' => payload_interpolated['user'],
67
+            'message' => message
68
+          }
68 69
 
69
-            post_params['device'] = event.payload['device'].presence  || options['device']
70
-            post_params['title'] = event.payload['title'].presence  || event.payload['subject'].presence  || options['title']
70
+          post_params['device'] = event.payload['device'].presence || payload_interpolated['device']
71
+          post_params['title'] = event.payload['title'].presence || event.payload['subject'].presence || payload_interpolated['title']
71 72
 
72
-            url = (event.payload['url'].presence  || options['url'] || '').to_s
73
-            url = url.slice 0..512
74
-            post_params['url'] = url
73
+          url = (event.payload['url'].presence || payload_interpolated['url'] || '').to_s
74
+          url = url.slice 0..512
75
+          post_params['url'] = url
75 76
 
76
-            url_title = (event.payload['url_title'].presence  || options['url_title']).to_s
77
-            url_title = url_title.slice 0..100
78
-            post_params['url_title'] = url_title
77
+          url_title = (event.payload['url_title'].presence || payload_interpolated['url_title']).to_s
78
+          url_title = url_title.slice 0..100
79
+          post_params['url_title'] = url_title
79 80
 
80
-            post_params['priority'] = (event.payload['priority'].presence  || options['priority']).to_i
81
+          post_params['priority'] = (event.payload['priority'].presence || payload_interpolated['priority']).to_i
81 82
 
82
-            if event.payload.has_key? 'timestamp'
83
-              post_params['timestamp'] = (event.payload['timestamp']).to_s
84
-            end
83
+          if event.payload.has_key? 'timestamp'
84
+            post_params['timestamp'] = (event.payload['timestamp']).to_s
85
+          end
85 86
 
86
-            post_params['sound'] = (event.payload['sound'].presence  || options['sound']).to_s
87
+          post_params['sound'] = (event.payload['sound'].presence || payload_interpolated['sound']).to_s
87 88
 
88
-            post_params['retry'] = (event.payload['retry'].presence  || options['retry']).to_i
89
+          post_params['retry'] = (event.payload['retry'].presence || payload_interpolated['retry']).to_i
89 90
 
90
-            post_params['expire'] = (event.payload['expire'].presence  || options['expire']).to_i
91
+          post_params['expire'] = (event.payload['expire'].presence || payload_interpolated['expire']).to_i
91 92
 
92
-            send_notification(post_params)
93
+          send_notification(post_params)
93 94
         end
94 95
       end
95 96
     end
96 97
 
97 98
     def working?
98
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
99
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
99 100
     end
100 101
 
101 102
     def send_notification(post_params)
102 103
       response = HTTParty.post(API_URL, :query => post_params)
103 104
       puts response
104 105
     end
105
-
106 106
   end
107 107
 end

+ 2 - 2
app/models/agents/sentiment_agent.rb

@@ -34,13 +34,13 @@ module Agents
34 34
     end
35 35
 
36 36
     def working?
37
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
37
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
38 38
     end
39 39
 
40 40
     def receive(incoming_events)
41 41
       anew = self.class.sentiment_hash
42 42
       incoming_events.each do |event|
43
-        Utils.values_at(event.payload, options['content']).each do |content|
43
+        Utils.values_at(event.payload, interpolated['content']).each do |content|
44 44
           sent_values = sentiment_values anew, content
45 45
           create_event :payload => { 'content' => content,
46 46
                                      'valence' => sent_values[0],

+ 8 - 7
app/models/agents/shell_command_agent.rb

@@ -15,7 +15,8 @@ module Agents
15 15
 
16 16
       `expected_update_period_in_days` is used to determine if the Agent is working.
17 17
 
18
-      ShellCommandAgent can also act upon received events. These events may contain their own `path` and `command` values. If they do not, ShellCommandAgent will use the configured options. For this reason, please specify defaults even if you are planning to have this Agent to respond to events.
18
+      ShellCommandAgent can also act upon received events. When receiving an event, this Agent's options can interpolate values from the incoming event.
19
+      For example, your command could be defined as `{{cmd}}`, in which case the event's `cmd` property would be used.
19 20
 
20 21
       The resulting event will contain the `command` which was executed, the `path` it was executed under, the `exit_status` of the command, the `errors`, and the actual `output`. ShellCommandAgent will not log an error if the result implies that something went wrong.
21 22
 
@@ -55,25 +56,25 @@ module Agents
55 56
     end
56 57
 
57 58
     def working?
58
-      Agents::ShellCommandAgent.should_run? && event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
59
+      Agents::ShellCommandAgent.should_run? && event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
59 60
     end
60 61
 
61 62
     def receive(incoming_events)
62 63
       incoming_events.each do |event|
63
-        handle(event.payload, event)
64
+        handle(interpolated(event.payload), event)
64 65
       end
65 66
     end
66 67
 
67 68
     def check
68
-      handle(options)
69
+      handle(interpolated)
69 70
     end
70 71
 
71 72
     private
72 73
 
73
-    def handle(opts = options, event = nil)
74
+    def handle(opts, event = nil)
74 75
       if Agents::ShellCommandAgent.should_run?
75
-        command = opts['command'] || options['command']
76
-        path = opts['path'] || options['path']
76
+        command = opts['command']
77
+        path = opts['path']
77 78
 
78 79
         result, errors, exit_status = run_command(path, command)
79 80
 

+ 4 - 5
app/models/agents/slack_agent.rb

@@ -1,6 +1,5 @@
1 1
 module Agents
2 2
   class SlackAgent < Agent
3
-    include LiquidInterpolatable
4 3
     cannot_be_scheduled!
5 4
     cannot_create_events!
6 5
 
@@ -45,20 +44,20 @@ module Agents
45 44
     end
46 45
 
47 46
     def webhook
48
-      options[:webhook].presence || DEFAULT_WEBHOOK
47
+      interpolated[:webhook].presence || DEFAULT_WEBHOOK
49 48
     end
50 49
 
51 50
     def username
52
-      options[:username].presence || DEFAULT_USERNAME
51
+      interpolated[:username].presence || DEFAULT_USERNAME
53 52
     end
54 53
 
55 54
     def slack_notifier
56
-      @slack_notifier ||= Slack::Notifier.new(options[:team_name], options[:auth_token], webhook, username: username)
55
+      @slack_notifier ||= Slack::Notifier.new(interpolated[:team_name], interpolated[:auth_token], webhook, username: username)
57 56
     end
58 57
 
59 58
     def receive(incoming_events)
60 59
       incoming_events.each do |event|
61
-        opts = interpolate_options options, event.payload
60
+        opts = interpolated(event.payload)
62 61
         slack_notifier.ping opts[:message], channel: opts[:channel], username: opts[:username]
63 62
       end
64 63
     end

+ 1 - 1
app/models/agents/stubhub_agent.rb

@@ -35,7 +35,7 @@ module Agents
35 35
     end
36 36
 
37 37
     def url
38
-      options['url']
38
+      interpolated['url']
39 39
     end
40 40
 
41 41
     def check

+ 6 - 8
app/models/agents/translation_agent.rb

@@ -1,7 +1,5 @@
1 1
 module Agents
2 2
   class TranslationAgent < Agent
3
-    include LiquidInterpolatable
4
-
5 3
     cannot_be_scheduled!
6 4
 
7 5
     description <<-MD
@@ -30,7 +28,7 @@ module Agents
30 28
     end
31 29
 
32 30
     def working?
33
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
31
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
34 32
     end
35 33
 
36 34
     def translate(text, to, access_token)
@@ -61,16 +59,16 @@ module Agents
61 59
 
62 60
     def receive(incoming_events)
63 61
       auth_uri = URI "https://datamarket.accesscontrol.windows.net/v2/OAuth2-13"
64
-      response = postform auth_uri, :client_id => options['client_id'],
65
-                                    :client_secret => options['client_secret'],
62
+      response = postform auth_uri, :client_id => interpolated['client_id'],
63
+                                    :client_secret => interpolated['client_secret'],
66 64
                                     :scope => "http://api.microsofttranslator.com",
67 65
                                     :grant_type => "client_credentials"
68 66
       access_token = JSON.parse(response.body)["access_token"]
69 67
       incoming_events.each do |event|
70 68
         translated_event = {}
71
-        options['content'].each_pair do |key, value|
72
-          to_be_translated = interpolate_string(value, event.payload)
73
-          translated_event[key] = translate(to_be_translated.first, options['to'], access_token)
69
+        opts = interpolated(event.payload)
70
+        opts['content'].each_pair do |key, value|
71
+          translated_event[key] = translate(value.first, opts['to'], access_token)
74 72
         end
75 73
         create_event :payload => translated_event
76 74
       end

+ 9 - 8
app/models/agents/trigger_agent.rb

@@ -1,7 +1,5 @@
1 1
 module Agents
2 2
   class TriggerAgent < Agent
3
-    include LiquidInterpolatable
4
-
5 3
     cannot_be_scheduled!
6 4
 
7 5
     VALID_COMPARISON_TYPES = %w[regex !regex field<value field<=value field==value field!=value field>=value field>value]
@@ -30,7 +28,7 @@ module Agents
30 28
 
31 29
     def validate_options
32 30
       unless options['expected_receive_period_in_days'].present? && options['rules'].present? &&
33
-          options['rules'].all? { |rule| rule['type'].present? && VALID_COMPARISON_TYPES.include?(rule['type']) && rule['value'].present? && rule['path'].present? }
31
+             options['rules'].all? { |rule| rule['type'].present? && VALID_COMPARISON_TYPES.include?(rule['type']) && rule['value'].present? && rule['path'].present? }
34 32
         errors.add(:base, "expected_receive_period_in_days, message, and rules, with a type, value, and path for every rule, are required")
35 33
       end
36 34
 
@@ -53,12 +51,15 @@ module Agents
53 51
     end
54 52
 
55 53
     def working?
56
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
54
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
57 55
     end
58 56
 
59 57
     def receive(incoming_events)
60 58
       incoming_events.each do |event|
61
-        match = options['rules'].all? do |rule|
59
+
60
+        opts = interpolated(event.payload)
61
+
62
+        match = opts['rules'].all? do |rule|
62 63
           value_at_path = Utils.value_at(event['payload'], rule['path'])
63 64
           rule_values = rule['value']
64 65
           rule_values = [rule_values] unless rule_values.is_a?(Array)
@@ -90,9 +91,9 @@ module Agents
90 91
         if match
91 92
           if keep_event?
92 93
             payload = event.payload.dup
93
-            payload['message'] = interpolate_string(options['message'], event.payload) if options['message'].present?
94
+            payload['message'] = opts['message'] if opts['message'].present?
94 95
           else
95
-            payload = { 'message' => interpolate_string(options['message'], event.payload) }
96
+            payload = { 'message' => opts['message'] }
96 97
           end
97 98
 
98 99
           create_event :payload => payload
@@ -101,7 +102,7 @@ module Agents
101 102
     end
102 103
 
103 104
     def keep_event?
104
-      options['keep_event'] == 'true'
105
+      interpolated['keep_event'] == 'true'
105 106
     end
106 107
   end
107 108
 end

+ 9 - 9
app/models/agents/twilio_agent.rb

@@ -39,18 +39,18 @@ module Agents
39 39
     end
40 40
 
41 41
     def receive(incoming_events)
42
-      @client = Twilio::REST::Client.new options['account_sid'], options['auth_token']
42
+      @client = Twilio::REST::Client.new interpolated['account_sid'], interpolated['auth_token']
43 43
       memory['pending_calls'] ||= {}
44 44
       incoming_events.each do |event|
45 45
         message = (event.payload['message'].presence || event.payload['text'].presence || event.payload['sms'].presence).to_s
46 46
         if message.present?
47
-          if options['receive_call'].to_s == 'true'
47
+          if interpolated(event.payload)['receive_call'].to_s == 'true'
48 48
             secret = SecureRandom.hex 3
49 49
             memory['pending_calls'][secret] = message
50 50
             make_call secret
51 51
           end
52 52
 
53
-          if options['receive_text'].to_s == 'true'
53
+          if interpolated(event.payload)['receive_text'].to_s == 'true'
54 54
             message = message.slice 0..160
55 55
             send_message message
56 56
           end
@@ -59,19 +59,19 @@ module Agents
59 59
     end
60 60
 
61 61
     def working?
62
-      last_receive_at && last_receive_at > options['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
62
+      last_receive_at && last_receive_at > interpolated['expected_receive_period_in_days'].to_i.days.ago && !recent_error_logs?
63 63
     end
64 64
 
65 65
     def send_message(message)
66
-      @client.account.sms.messages.create :from => options['sender_cell'],
67
-                                          :to => options['receiver_cell'],
66
+      @client.account.sms.messages.create :from => interpolated['sender_cell'],
67
+                                          :to => interpolated['receiver_cell'],
68 68
                                           :body => message
69 69
     end
70 70
 
71 71
     def make_call(secret)
72
-      @client.account.calls.create :from => options['sender_cell'],
73
-                                   :to => options['receiver_cell'],
74
-                                   :url => post_url(options['server_url'], secret)
72
+      @client.account.calls.create :from => interpolated['sender_cell'],
73
+                                   :to => interpolated['receiver_cell'],
74
+                                   :url => post_url(interpolated['server_url'], secret)
75 75
     end
76 76
 
77 77
     def post_url(server_url, secret)

+ 2 - 3
app/models/agents/twitter_publish_agent.rb

@@ -3,7 +3,6 @@ require "twitter"
3 3
 module Agents
4 4
   class TwitterPublishAgent < Agent
5 5
     include TwitterConcern
6
-    include LiquidInterpolatable
7 6
 
8 7
     cannot_be_scheduled!
9 8
 
@@ -22,7 +21,7 @@ module Agents
22 21
     end
23 22
 
24 23
     def working?
25
-      event_created_within?(options['expected_update_period_in_days']) && most_recent_event && most_recent_event.payload['success'] == true && !recent_error_logs?
24
+      event_created_within?(interpolated['expected_update_period_in_days']) && most_recent_event && most_recent_event.payload['success'] == true && !recent_error_logs?
26 25
     end
27 26
 
28 27
     def default_options
@@ -38,7 +37,7 @@ module Agents
38 37
         incoming_events = incoming_events.first(20)
39 38
       end
40 39
       incoming_events.each do |event|
41
-        tweet_text = interpolate_string(options['message'], event.payload)
40
+        tweet_text = interpolated(event.payload)['message']
42 41
         begin
43 42
           tweet = publish_tweet tweet_text
44 43
           create_event :payload => {

+ 5 - 5
app/models/agents/twitter_stream_agent.rb

@@ -61,7 +61,7 @@ module Agents
61 61
     end
62 62
 
63 63
     def working?
64
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
64
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
65 65
     end
66 66
 
67 67
     def default_options
@@ -76,7 +76,7 @@ module Agents
76 76
       filter = lookup_filter(filter)
77 77
 
78 78
       if filter
79
-        if options['generate'] == "counts"
79
+        if interpolated['generate'] == "counts"
80 80
           # Avoid memory pollution by reloading the Agent.
81 81
           agent = Agent.find(id)
82 82
           agent.memory['filter_counts'] ||= {}
@@ -91,7 +91,7 @@ module Agents
91 91
     end
92 92
 
93 93
     def check
94
-      if options['generate'] == "counts" && memory['filter_counts'] && memory['filter_counts'].length > 0
94
+      if interpolated['generate'] == "counts" && memory['filter_counts'] && memory['filter_counts'].length > 0
95 95
         memory['filter_counts'].each do |filter, count|
96 96
           create_event :payload => { 'filter' => filter, 'count' => count, 'time' => Time.now.to_i }
97 97
         end
@@ -102,7 +102,7 @@ module Agents
102 102
     protected
103 103
 
104 104
     def lookup_filter(filter)
105
-      options['filters'].each do |known_filter|
105
+      interpolated['filters'].each do |known_filter|
106 106
         if known_filter == filter
107 107
           return filter
108 108
         elsif known_filter.is_a?(Array)
@@ -115,7 +115,7 @@ module Agents
115 115
 
116 116
     def remove_unused_keys!(agent, base)
117 117
       if agent.memory[base]
118
-        (agent.memory[base].keys - agent.options['filters'].map {|f| f.is_a?(Array) ? f.first.to_s : f.to_s }).each do |removed_key|
118
+        (agent.memory[base].keys - agent.interpolated['filters'].map {|f| f.is_a?(Array) ? f.first.to_s : f.to_s }).each do |removed_key|
119 119
           agent.memory[base].delete(removed_key)
120 120
         end
121 121
       end

+ 5 - 5
app/models/agents/twitter_user_agent.rb

@@ -47,7 +47,7 @@ module Agents
47 47
     default_schedule "every_1h"
48 48
 
49 49
     def working?
50
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
50
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
51 51
     end
52 52
 
53 53
     def default_options
@@ -72,15 +72,15 @@ module Agents
72 72
     end
73 73
 
74 74
     def starting_at
75
-      if options[:starting_at].present?
76
-        Time.parse(options[:starting_at]) rescue created_at
75
+      if interpolated[:starting_at].present?
76
+        Time.parse(interpolated[:starting_at]) rescue created_at
77 77
       else
78 78
         created_at
79 79
       end
80 80
     end
81 81
 
82 82
     def include_retweets?
83
-      options[:include_retweets] != "false"
83
+      interpolated[:include_retweets] != "false"
84 84
     end
85 85
 
86 86
     def check
@@ -89,7 +89,7 @@ module Agents
89 89
       opts.merge! :since_id => since_id unless since_id.nil?
90 90
 
91 91
       # http://rdoc.info/gems/twitter/Twitter/REST/Timelines#user_timeline-instance_method
92
-      tweets = twitter.user_timeline(options['username'], opts)
92
+      tweets = twitter.user_timeline(interpolated['username'], opts)
93 93
 
94 94
       tweets.each do |tweet|
95 95
         if tweet.created_at >= starting_at

+ 7 - 7
app/models/agents/weather_agent.rb

@@ -51,11 +51,11 @@ module Agents
51 51
     default_schedule "8pm"
52 52
 
53 53
     def working?
54
-      event_created_within?((options['expected_update_period_in_days'].presence || 2).to_i) && !recent_error_logs?
54
+      event_created_within?((interpolated['expected_update_period_in_days'].presence || 2).to_i) && !recent_error_logs?
55 55
     end
56 56
 
57 57
     def key_setup?
58
-      options['api_key'].present? && options['api_key'] != "your-key"
58
+      interpolated['api_key'].present? && interpolated['api_key'] != "your-key"
59 59
     end
60 60
 
61 61
     def default_options
@@ -69,15 +69,15 @@ module Agents
69 69
     end
70 70
 
71 71
     def service
72
-      options["service"].presence || "wunderground"
72
+      interpolated["service"].presence || "wunderground"
73 73
     end
74 74
 
75 75
     def which_day
76
-      (options["which_day"].presence || 1).to_i
76
+      (interpolated["which_day"].presence || 1).to_i
77 77
     end
78 78
 
79 79
     def location
80
-      options["location"].presence || options["zipcode"]
80
+      interpolated["location"].presence || interpolated["zipcode"]
81 81
     end
82 82
 
83 83
     def validate_options
@@ -89,12 +89,12 @@ module Agents
89 89
     end
90 90
 
91 91
     def wunderground
92
-      Wunderground.new(options['api_key']).forecast_for(location)['forecast']['simpleforecast']['forecastday'] if key_setup?
92
+      Wunderground.new(interpolated['api_key']).forecast_for(location)['forecast']['simpleforecast']['forecastday'] if key_setup?
93 93
     end
94 94
 
95 95
     def forecastio
96 96
       if key_setup?
97
-        ForecastIO.api_key = options['api_key']
97
+        ForecastIO.api_key = interpolated['api_key']
98 98
         lat, lng = location.split(',')
99 99
         ForecastIO.forecast(lat,lng)['daily']['data']
100 100
       end

+ 4 - 4
app/models/agents/webhook_agent.rb

@@ -27,7 +27,7 @@ module Agents
27 27
     event_description do
28 28
       <<-MD
29 29
         The event payload is base on the value of the `payload_path` option,
30
-        which is set to `#{options['payload_path']}`.
30
+        which is set to `#{interpolated['payload_path']}`.
31 31
       MD
32 32
     end
33 33
 
@@ -40,7 +40,7 @@ module Agents
40 40
     def receive_web_request(params, method, format)
41 41
       secret = params.delete('secret')
42 42
       return ["Please use POST requests only", 401] unless method == "post"
43
-      return ["Not Authorized", 401] unless secret == options['secret']
43
+      return ["Not Authorized", 401] unless secret == interpolated['secret']
44 44
 
45 45
       create_event(:payload => payload_for(params))
46 46
 
@@ -48,7 +48,7 @@ module Agents
48 48
     end
49 49
 
50 50
     def working?
51
-      event_created_within?(options['expected_receive_period_in_days']) && !recent_error_logs?
51
+      event_created_within?(interpolated['expected_receive_period_in_days']) && !recent_error_logs?
52 52
     end
53 53
 
54 54
     def validate_options
@@ -58,7 +58,7 @@ module Agents
58 58
     end
59 59
 
60 60
     def payload_for(params)
61
-      Utils.value_at(params, options['payload_path']) || {}
61
+      Utils.value_at(params, interpolated['payload_path']) || {}
62 62
     end
63 63
   end
64 64
 end

+ 22 - 22
app/models/agents/website_agent.rb

@@ -55,11 +55,11 @@ module Agents
55 55
     MD
56 56
 
57 57
     event_description do
58
-      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print options['extract']}"
58
+      "Events will have the fields you specified.  Your options look like:\n\n    #{Utils.pretty_print interpolated['extract']}"
59 59
     end
60 60
 
61 61
     def working?
62
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
62
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
63 63
     end
64 64
 
65 65
     def default_options
@@ -125,7 +125,7 @@ module Agents
125 125
     end
126 126
 
127 127
     def check
128
-      check_url options['url']
128
+      check_url interpolated['url']
129 129
     end
130 130
 
131 131
     def check_url(in_url)
@@ -136,7 +136,7 @@ module Agents
136 136
         response = faraday.get(url)
137 137
         if response.success?
138 138
           body = response.body
139
-          if (encoding = options['force_encoding']).present?
139
+          if (encoding = interpolated['force_encoding']).present?
140 140
             body = body.encode(Encoding::UTF_8, encoding)
141 141
           end
142 142
           doc = parse(body)
@@ -148,7 +148,7 @@ module Agents
148 148
             end
149 149
           else
150 150
             output = {}
151
-            options['extract'].each do |name, extraction_details|
151
+            interpolated['extract'].each do |name, extraction_details|
152 152
               if extraction_type == "json"
153 153
                 result = Utils.values_at(doc, extraction_details['path'])
154 154
                 log "Extracting #{extraction_type} at #{extraction_details['path']}: #{result}"
@@ -181,17 +181,17 @@ module Agents
181 181
               output[name] = result
182 182
             end
183 183
 
184
-            num_unique_lengths = options['extract'].keys.map { |name| output[name].length }.uniq
184
+            num_unique_lengths = interpolated['extract'].keys.map { |name| output[name].length }.uniq
185 185
 
186 186
             if num_unique_lengths.length != 1
187
-              error "Got an uneven number of matches for #{options['name']}: #{options['extract'].inspect}"
187
+              error "Got an uneven number of matches for #{interpolated['name']}: #{interpolated['extract'].inspect}"
188 188
               return
189 189
             end
190 190
 
191 191
             old_events = previous_payloads num_unique_lengths.first
192 192
             num_unique_lengths.first.times do |index|
193 193
               result = {}
194
-              options['extract'].keys.each do |name|
194
+              interpolated['extract'].keys.each do |name|
195 195
                 result[name] = output[name][index]
196 196
                 if name.to_s == 'url'
197 197
                   result[name] = (response.env[:url] + result[name]).to_s
@@ -223,11 +223,11 @@ module Agents
223 223
     # If mode is set to 'on_change', this method may return false and update an existing
224 224
     # event to expire further in the future.
225 225
     def store_payload!(old_events, result)
226
-      if !options['mode'].present?
226
+      if !interpolated['mode'].present?
227 227
         return true
228
-      elsif options['mode'].to_s == "all"
228
+      elsif interpolated['mode'].to_s == "all"
229 229
         return true
230
-      elsif options['mode'].to_s == "on_change"
230
+      elsif interpolated['mode'].to_s == "on_change"
231 231
         result_json = result.to_json
232 232
         old_events.each do |old_event|
233 233
           if old_event.payload.to_json == result_json
@@ -238,12 +238,12 @@ module Agents
238 238
         end
239 239
         return true
240 240
       end
241
-      raise "Illegal options[mode]: " + options['mode'].to_s
241
+      raise "Illegal options[mode]: " + interpolated['mode'].to_s
242 242
     end
243 243
 
244 244
     def previous_payloads(num_events)
245
-      if options['uniqueness_look_back'].present?
246
-        look_back = options['uniqueness_look_back'].to_i
245
+      if interpolated['uniqueness_look_back'].present?
246
+        look_back = interpolated['uniqueness_look_back'].to_i
247 247
       else
248 248
         # Larger of UNIQUENESS_FACTOR * num_events and UNIQUENESS_LOOK_BACK
249 249
         look_back = UNIQUENESS_FACTOR * num_events
@@ -251,18 +251,18 @@ module Agents
251 251
           look_back = UNIQUENESS_LOOK_BACK
252 252
         end
253 253
       end
254
-      events.order("id desc").limit(look_back) if options['mode'].present? && options['mode'].to_s == "on_change"
254
+      events.order("id desc").limit(look_back) if interpolated['mode'].present? && interpolated['mode'].to_s == "on_change"
255 255
     end
256 256
 
257 257
     def extract_full_json?
258
-      !options['extract'].present? && extraction_type == "json"
258
+      !interpolated['extract'].present? && extraction_type == "json"
259 259
     end
260 260
 
261 261
     def extraction_type
262
-      (options['type'] || begin
263
-        if options['url'] =~ /\.(rss|xml)$/i
262
+      (interpolated['type'] || begin
263
+        if interpolated['url'] =~ /\.(rss|xml)$/i
264 264
           "xml"
265
-        elsif options['url'] =~ /\.json$/i
265
+        elsif interpolated['url'] =~ /\.json$/i
266 266
           "json"
267 267
         else
268 268
           "html"
@@ -295,7 +295,7 @@ module Agents
295 295
       @faraday ||= Faraday.new { |builder|
296 296
         builder.headers = headers if headers.length > 0
297 297
 
298
-        if (user_agent = options['user_agent']).present?
298
+        if (user_agent = interpolated['user_agent']).present?
299 299
           builder.headers[:user_agent] = user_agent
300 300
         end
301 301
 
@@ -318,7 +318,7 @@ module Agents
318 318
     end
319 319
 
320 320
     def basic_auth_credentials
321
-      case value = options['basic_auth']
321
+      case value = interpolated['basic_auth']
322 322
       when nil, ''
323 323
         return nil
324 324
       when Array
@@ -330,7 +330,7 @@ module Agents
330 330
     end
331 331
 
332 332
     def headers
333
-      options['headers'].presence || {}
333
+      interpolated['headers'].presence || {}
334 334
     end
335 335
   end
336 336
 end

+ 3 - 3
app/models/agents/weibo_publish_agent.rb

@@ -21,13 +21,13 @@ module Agents
21 21
 
22 22
     def validate_options
23 23
       unless options['uid'].present? &&
24
-        options['expected_update_period_in_days'].present?
24
+             options['expected_update_period_in_days'].present?
25 25
         errors.add(:base, "expected_update_period_in_days and uid are required")
26 26
       end
27 27
     end
28 28
 
29 29
     def working?
30
-      event_created_within?(options['expected_update_period_in_days']) && most_recent_event.payload['success'] == true && !recent_error_logs?
30
+      event_created_within?(interpolated['expected_update_period_in_days']) && most_recent_event.payload['success'] == true && !recent_error_logs?
31 31
     end
32 32
 
33 33
     def default_options
@@ -47,7 +47,7 @@ module Agents
47 47
         incoming_events = incoming_events.first(20)
48 48
       end
49 49
       incoming_events.each do |event|
50
-        tweet_text = Utils.value_at(event.payload, options['message_path'])
50
+        tweet_text = Utils.value_at(event.payload, interpolated(event.payload)['message_path'])
51 51
         if event.agent.type == "Agents::TwitterUserAgent"
52 52
           tweet_text = unwrap_tco_urls(tweet_text, event.payload)
53 53
         end

+ 3 - 3
app/models/agents/weibo_user_agent.rb

@@ -71,13 +71,13 @@ module Agents
71 71
 
72 72
     def validate_options
73 73
       unless options['uid'].present? &&
74
-        options['expected_update_period_in_days'].present?
74
+             options['expected_update_period_in_days'].present?
75 75
         errors.add(:base, "expected_update_period_in_days and uid are required")
76 76
       end
77 77
     end
78 78
 
79 79
     def working?
80
-      event_created_within?(options['expected_update_period_in_days']) && !recent_error_logs?
80
+      event_created_within?(interpolated['expected_update_period_in_days']) && !recent_error_logs?
81 81
     end
82 82
 
83 83
     def default_options
@@ -92,7 +92,7 @@ module Agents
92 92
 
93 93
     def check
94 94
       since_id = memory['since_id'] || nil
95
-      opts = {:uid => options['uid'].to_i}
95
+      opts = {:uid => interpolated['uid'].to_i}
96 96
       opts.merge! :since_id => since_id unless since_id.nil?
97 97
 
98 98
       # http://open.weibo.com/wiki/2/statuses/user_timeline/en

+ 19 - 0
app/models/scenario.rb

@@ -0,0 +1,19 @@
1
+class Scenario < ActiveRecord::Base
2
+  include HasGuid
3
+
4
+  attr_accessible :name, :agent_ids, :description, :public, :source_url
5
+
6
+  belongs_to :user, :counter_cache => :scenario_count, :inverse_of => :scenarios
7
+  has_many :scenario_memberships, :dependent => :destroy, :inverse_of => :scenario
8
+  has_many :agents, :through => :scenario_memberships, :inverse_of => :scenarios
9
+
10
+  validates_presence_of :name, :user
11
+
12
+  validate :agents_are_owned
13
+
14
+  protected
15
+
16
+  def agents_are_owned
17
+    errors.add(:agents, "must be owned by you") unless agents.all? {|s| s.user == user }
18
+  end
19
+end

+ 256 - 0
app/models/scenario_import.rb

@@ -0,0 +1,256 @@
1
+require 'ostruct'
2
+
3
+# This is a helper class for managing Scenario imports, used by the ScenarioImportsController.  This class behaves much
4
+# like a normal ActiveRecord object, with validations and callbacks.  However, it is never persisted to the database.
5
+class ScenarioImport
6
+  include ActiveModel::Model
7
+  include ActiveModel::Callbacks
8
+  include ActiveModel::Validations::Callbacks
9
+
10
+  DANGEROUS_AGENT_TYPES = %w[Agents::ShellCommandAgent]
11
+  URL_REGEX = /\Ahttps?:\/\//i
12
+
13
+  attr_accessor :file, :url, :data, :do_import, :merges
14
+
15
+  attr_reader :user
16
+
17
+  before_validation :parse_file
18
+  before_validation :fetch_url
19
+
20
+  validate :validate_presence_of_file_url_or_data
21
+  validates_format_of :url, :with => URL_REGEX, :allow_nil => true, :allow_blank => true, :message => "appears to be invalid"
22
+  validate :validate_data
23
+  validate :generate_diff
24
+
25
+  def step_one?
26
+    data.blank?
27
+  end
28
+
29
+  def step_two?
30
+    data.present?
31
+  end
32
+
33
+  def set_user(user)
34
+    @user = user
35
+  end
36
+
37
+  def existing_scenario
38
+    @existing_scenario ||= user.scenarios.find_by(:guid => parsed_data["guid"])
39
+  end
40
+
41
+  def dangerous?
42
+    (parsed_data['agents'] || []).any? { |agent| DANGEROUS_AGENT_TYPES.include?(agent['type']) }
43
+  end
44
+
45
+  def parsed_data
46
+    @parsed_data ||= (data && JSON.parse(data) rescue {}) || {}
47
+  end
48
+
49
+  def agent_diffs
50
+    @agent_diffs || generate_diff
51
+  end
52
+
53
+  def should_import?
54
+    do_import == "1"
55
+  end
56
+
57
+  def import(options = {})
58
+    success = true
59
+    guid = parsed_data['guid']
60
+    description = parsed_data['description']
61
+    name = parsed_data['name']
62
+    links = parsed_data['links']
63
+    source_url = parsed_data['source_url'].presence || nil
64
+    @scenario = user.scenarios.where(:guid => guid).first_or_initialize
65
+    @scenario.update_attributes!(:name => name, :description => description,
66
+                                 :source_url => source_url, :public => false)
67
+
68
+    unless options[:skip_agents]
69
+      created_agents = agent_diffs.map do |agent_diff|
70
+        agent = agent_diff.agent || Agent.build_for_type("Agents::" + agent_diff.type.incoming, user)
71
+        agent.guid = agent_diff.guid.incoming
72
+        agent.attributes = { :name => agent_diff.name.updated,
73
+                             :disabled => agent_diff.disabled.updated, # == "true"
74
+                             :options => agent_diff.options.updated,
75
+                             :scenario_ids => [@scenario.id] }
76
+        agent.schedule = agent_diff.schedule.updated if agent_diff.schedule.present?
77
+        agent.keep_events_for = agent_diff.keep_events_for.updated if agent_diff.keep_events_for.present?
78
+        agent.propagate_immediately = agent_diff.propagate_immediately.updated if agent_diff.propagate_immediately.present? # == "true"
79
+        unless agent.save
80
+          success = false
81
+          errors.add(:base, "Errors when saving '#{agent_diff.name.incoming}': #{agent.errors.full_messages.to_sentence}")
82
+        end
83
+        agent
84
+      end
85
+
86
+      links.each do |link|
87
+        receiver = created_agents[link['receiver']]
88
+        source = created_agents[link['source']]
89
+        receiver.sources << source unless receiver.sources.include?(source)
90
+      end
91
+    end
92
+
93
+    success
94
+  end
95
+
96
+  def scenario
97
+    @scenario || @existing_scenario
98
+  end
99
+
100
+  def will_request_local?(url_root)
101
+    data.blank? && file.blank? && url.present? && url.starts_with?(url_root)
102
+  end
103
+
104
+  protected
105
+
106
+  def parse_file
107
+    if data.blank? && file.present?
108
+      self.data = file.read
109
+    end
110
+  end
111
+
112
+  def fetch_url
113
+    if data.blank? && url.present? && url =~ URL_REGEX
114
+      self.data = Faraday.get(url).body
115
+    end
116
+  end
117
+
118
+  def validate_data
119
+    if data.present?
120
+      @parsed_data = JSON.parse(data) rescue {}
121
+      if (%w[name guid agents] - @parsed_data.keys).length > 0
122
+        errors.add(:base, "The provided data does not appear to be a valid Scenario.")
123
+        self.data = nil
124
+      end
125
+    else
126
+      @parsed_data = nil
127
+    end
128
+  end
129
+
130
+  def validate_presence_of_file_url_or_data
131
+    unless file.present? || url.present? || data.present?
132
+      errors.add(:base, "Please provide either a Scenario JSON File or a Public Scenario URL.")
133
+    end
134
+  end
135
+
136
+  def generate_diff
137
+    @agent_diffs = (parsed_data['agents'] || []).map.with_index do |agent_data, index|
138
+      # AgentDiff is defined at the end of this file.
139
+      agent_diff = AgentDiff.new(agent_data)
140
+      if existing_scenario
141
+        # If this Agent exists already, update the AgentDiff with the local version's information.
142
+        agent_diff.diff_with! existing_scenario.agents.find_by(:guid => agent_data['guid'])
143
+
144
+        begin
145
+          # Update the AgentDiff with any hand-merged changes coming from the UI.  This only happens when this
146
+          # Agent already exists locally and has conflicting changes.
147
+          agent_diff.update_from! merges[index.to_s] if merges
148
+        rescue JSON::ParserError
149
+          errors.add(:base, "Your updated options for '#{agent_data['name']}' were unparsable.")
150
+        end
151
+      end
152
+      agent_diff
153
+    end
154
+  end
155
+
156
+  # AgentDiff is a helper object that encapsulates an incoming Agent.  All fields will be returned as an array
157
+  # of either one or two values.  The first value is the incoming value, the second is the existing value, if
158
+  # it differs from the incoming value.
159
+  class AgentDiff < OpenStruct
160
+    class FieldDiff
161
+      attr_accessor :incoming, :current, :updated
162
+
163
+      def initialize(incoming)
164
+        @incoming = incoming
165
+        @updated = incoming
166
+      end
167
+
168
+      def set_current(current)
169
+        @current = current
170
+        @requires_merge = (incoming != current)
171
+      end
172
+
173
+      def requires_merge?
174
+        @requires_merge
175
+      end
176
+    end
177
+
178
+    def initialize(agent_data)
179
+      super()
180
+      @requires_merge = false
181
+      self.agent = nil
182
+      store! agent_data
183
+    end
184
+
185
+    BASE_FIELDS = %w[name schedule keep_events_for propagate_immediately disabled guid]
186
+
187
+    def agent_exists?
188
+      !!agent
189
+    end
190
+
191
+    def requires_merge?
192
+      @requires_merge
193
+    end
194
+
195
+    def store!(agent_data)
196
+      self.type = FieldDiff.new(agent_data["type"].split("::").pop)
197
+      self.options = FieldDiff.new(agent_data['options'] || {})
198
+      BASE_FIELDS.each do |option|
199
+        self[option] = FieldDiff.new(agent_data[option]) if agent_data.has_key?(option)
200
+      end
201
+    end
202
+
203
+    def diff_with!(agent)
204
+      return unless agent.present?
205
+
206
+      self.agent = agent
207
+
208
+      type.set_current(agent.short_type)
209
+      options.set_current(agent.options || {})
210
+
211
+      @requires_merge ||= type.requires_merge?
212
+      @requires_merge ||= options.requires_merge?
213
+
214
+      BASE_FIELDS.each do |field|
215
+        next unless self[field].present?
216
+        self[field].set_current(agent.send(field))
217
+
218
+        @requires_merge ||= self[field].requires_merge?
219
+      end
220
+    end
221
+
222
+    def update_from!(merges)
223
+      each_field do |field, value, selection_options|
224
+        value.updated = merges[field]
225
+      end
226
+
227
+      if options.requires_merge?
228
+        options.updated = JSON.parse(merges['options'])
229
+      end
230
+    end
231
+
232
+    def each_field
233
+      boolean = [["True", "true"], ["False", "false"]]
234
+      yield 'name', name if name.requires_merge?
235
+      yield 'schedule', schedule, Agent::SCHEDULES.map {|s| [s.humanize.titleize, s] } if self['schedule'].present? && schedule.requires_merge?
236
+      yield 'keep_events_for', keep_events_for, Agent::EVENT_RETENTION_SCHEDULES if self['keep_events_for'].present? && keep_events_for.requires_merge?
237
+      yield 'propagate_immediately', propagate_immediately, boolean if self['propagate_immediately'].present? && propagate_immediately.requires_merge?
238
+      yield 'disabled', disabled, boolean if disabled.requires_merge?
239
+    end
240
+
241
+    # Unfortunately Ruby 1.9's OpenStruct doesn't expose [] and []=.
242
+    unless instance_methods.include?(:[]=)
243
+      def [](key)
244
+        self.send(sanitize key)
245
+      end
246
+
247
+      def []=(key, val)
248
+        self.send("#{sanitize key}=", val)
249
+      end
250
+
251
+      def sanitize(key)
252
+        key.gsub(/[^a-zA-Z0-9_-]/, '')
253
+      end
254
+    end
255
+  end
256
+end

+ 4 - 0
app/models/scenario_membership.rb

@@ -0,0 +1,4 @@
1
+class ScenarioMembership < ActiveRecord::Base
2
+  belongs_to :agent, :inverse_of => :scenario_memberships
3
+  belongs_to :scenario, :inverse_of => :scenario_memberships
4
+end

+ 2 - 2
app/models/user.rb

@@ -26,11 +26,11 @@ class User < ActiveRecord::Base
26 26
   has_many :events, -> { order("events.created_at desc") }, :dependent => :delete_all, :inverse_of => :user
27 27
   has_many :agents, -> { order("agents.created_at desc") }, :dependent => :destroy, :inverse_of => :user
28 28
   has_many :logs, :through => :agents, :class_name => "AgentLog"
29
+  has_many :scenarios, :inverse_of => :user, :dependent => :destroy
29 30
   has_many :services, -> { order("services.name")}, :dependent => :destroy
30
-  
31 31
 
32 32
   def available_services
33
-    Service.where("user_id = ? or global = true", self.id).order("services.name desc") 
33
+    Service.where("user_id = ? or global = true", self.id).order("services.name desc")
34 34
   end
35 35
 
36 36
   # Allow users to login via either email or username.

+ 12 - 2
app/views/agents/_action_menu.html.erb

@@ -27,15 +27,25 @@
27 27
     <% end %>
28 28
   </li>
29 29
 
30
+  <% if agent.scenarios.length > 0 %>
31
+    <li class="divider"></li>
32
+
33
+    <% agent.scenarios.each do |scenario| %>
34
+      <li>
35
+        <%= link_to "<span class='color-warning glyphicon glyphicon-remove-circle'></span> Remove from <span class='scenario label label-info'>#{h scenario.name}</span>".html_safe, leave_scenario_agent_path(agent, :scenario_id => scenario.to_param, :return => returnTo), method: :put, :tabindex => "-1" %>
36
+      </li>
37
+    <% end %>
38
+  <% end %>
39
+
30 40
   <li class="divider"></li>
31 41
 
32 42
   <% if agent.can_create_events? && agent.events.count > 0 %>
33 43
     <li>
34
-      <%= link_to '<span class="color-danger glyphicon glyphicon-trash"></span> Delete all events'.html_safe, remove_events_agent_path(agent), method: :delete, data: {confirm: 'Are you sure you want to delete ALL events for this Agent?'}, :tabindex => "-1" %>
44
+      <%= link_to '<span class="color-danger glyphicon glyphicon-trash"></span> Delete all events'.html_safe, remove_events_agent_path(agent, :return => returnTo), method: :delete, data: {confirm: 'Are you sure you want to delete ALL emitted events for this Agent?'}, :tabindex => "-1" %>
35 45
     </li>
36 46
   <% end %>
37 47
 
38 48
   <li>
39
-    <%= link_to '<span class="color-danger glyphicon glyphicon-remove"></span> Delete agent'.html_safe, agent_path(agent), method: :delete, data: { confirm: 'Are you sure?' }, :tabindex => "-1" %>
49
+    <%= link_to '<span class="color-danger glyphicon glyphicon-remove"></span> Delete agent'.html_safe, agent_path(agent, :return => returnTo), method: :delete, data: { confirm: 'Are you sure that you want to permanently delete this Agent?' }, :tabindex => "-1" %>
40 50
   </li>
41 51
 </ul>

+ 14 - 2
app/views/agents/_form.html.erb

@@ -50,6 +50,7 @@
50 50
           <div class='event-related-region' data-can-create-events="<%= @agent.can_create_events? %>">
51 51
             <div class="form-group">
52 52
               <%= f.label :keep_events_for, "Keep events" %>
53
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="In order to conserve disk space, you can choose to have events created by this Agent expire after a certain period of time.  Make sure you keep them long enough to allow any subsequent Agents to make use of them."></span>
53 54
               <%= f.select :keep_events_for, options_for_select(Agent::EVENT_RETENTION_SCHEDULES, @agent.keep_events_for), {}, :class => 'form-control' %>
54 55
             </div>
55 56
           </div>
@@ -68,13 +69,24 @@
68 69
               <% end %>
69 70
             </div>
70 71
           </div>
72
+
73
+          <% if current_user.scenario_count > 0 %>
74
+            <div class="form-group">
75
+              <%= f.label :scenarios %>
76
+              <span class="glyphicon glyphicon-question-sign hover-help" data-content="Use Scenarios to group sets of Agents, both for organization, and to make them easy to export and share."></span>
77
+              <%= f.select(:scenario_ids,
78
+                           options_for_select(current_user.scenarios.pluck(:name, :id), @agent.scenario_ids),
79
+                           {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
80
+            </div>
81
+          <% end %>
82
+
71 83
         </div>
72 84
 
73 85
         <!-- Form controls full width -->
74 86
         <div class="col-md-12">
75 87
           <div class="form-group">
76 88
             <%= f.label :options %>
77
-            <textarea rows="10" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
89
+            <textarea rows="15" id="agent_options" name="agent[options]" class="form-control live-json-editor <%= (@agent.new_record? && @agent.options == {}) ? "showing-default" : "" %>">
78 90
               <%= Utils.jsonify((@agent.new_record? && @agent.options == {}) ? @agent.default_options : @agent.options) %>
79 91
             </textarea>
80 92
           </div>
@@ -101,7 +113,7 @@
101 113
 
102 114
   <div class='row'>
103 115
     <div class="col-md-12">
104
-      <%= f.submit :class => "btn btn-primary" %>
116
+      <%= f.submit "Save", :class => "btn btn-primary" %>
105 117
     </div>
106 118
   </div>
107 119
       

+ 75 - 0
app/views/agents/_table.html.erb

@@ -0,0 +1,75 @@
1
+<div class='table-responsive'>
2
+  <table class='table table-striped'>
3
+    <tr>
4
+      <th>Name</th>
5
+      <th>Schedule</th>
6
+      <th>Last Check</th>
7
+      <th>Last Event Out</th>
8
+      <th>Last Event In</th>
9
+      <th>Events Created</th>
10
+      <th>Working?</th>
11
+      <th></th>
12
+    </tr>
13
+
14
+    <% @agents.each do |agent| %>
15
+      <tr>
16
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
17
+          <%= link_to agent.name, agent_path(agent) %>
18
+          <br/>
19
+          <span class='text-muted'><%= agent.short_type.titleize %></span>
20
+          <% if agent.scenarios.present? %>
21
+            <span>
22
+              <%= scenario_links(agent) %>
23
+            </span>
24
+          <% end %>
25
+        </td>
26
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
27
+          <% if agent.can_be_scheduled? %>
28
+            <%= agent.schedule.to_s.humanize.titleize %>
29
+          <% else %>
30
+            <span class='not-applicable'></span>
31
+          <% end %>
32
+        </td>
33
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
34
+          <% if agent.can_be_scheduled? %>
35
+            <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
36
+          <% else %>
37
+            <span class='not-applicable'></span>
38
+          <% end %>
39
+        </td>
40
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
41
+          <% if agent.can_create_events? %>
42
+            <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
43
+          <% else %>
44
+            <span class='not-applicable'></span>
45
+          <% end %>
46
+        </td>
47
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
48
+          <% if agent.can_receive_events? %>
49
+            <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
50
+          <% else %>
51
+            <span class='not-applicable'></span>
52
+          <% end %>
53
+        </td>
54
+        <td class='<%= "agent-disabled" if agent.disabled? %>'>
55
+          <% if agent.can_create_events? %>
56
+            <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
57
+          <% else %>
58
+            <span class='not-applicable'></span>
59
+          <% end %>
60
+        </td>
61
+        <td><%= working(agent) %></td>
62
+        <td>
63
+          <div class="btn-group">
64
+            <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
65
+              <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
66
+            </button>
67
+            <%= render 'agents/action_menu', :agent => agent, :returnTo => (defined?(returnTo) && returnTo) || "index" %>
68
+          </div>
69
+        </td>
70
+      </tr>
71
+    <% end %>
72
+  </table>
73
+</div>
74
+
75
+<%= paginate @agents, :theme => 'twitter-bootstrap-3' %>

+ 1 - 1
app/views/agents/agent_views/manual_event_agent/_show.html.erb

@@ -14,7 +14,7 @@
14 14
 
15 15
 <script>
16 16
   $(function () {
17
-    var payloadJsonEditor = window.setupJsonEditor($(".payload-editor"));
17
+    var payloadJsonEditor = window.setupJsonEditor($(".payload-editor"))[0];
18 18
     $("#create-event-form").submit(function (e) {
19 19
       e.preventDefault();
20 20
       var $form = $("#create-event-form");

+ 1 - 1
app/views/agents/diagram.html.erb

@@ -5,7 +5,7 @@
5 5
         <h2>Agent Event Flow</h2>
6 6
       </div>
7 7
       <div class="btn-group">
8
-        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, agents_path, class: "btn btn-default" %>
8
+        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, (params[:scenario_id] ? scenario_path(params[:scenario_id]) : agents_path), class: "btn btn-default" %>
9 9
       </div>
10 10
 
11 11
       <div class='digraph'>

+ 1 - 70
app/views/agents/index.html.erb

@@ -5,76 +5,7 @@
5 5
         <h2>Your Agents</h2>
6 6
       </div>
7 7
 
8
-      <div class='table-responsive'>
9
-        <table class='table table-striped'>
10
-          <tr>
11
-            <th>Name</th>
12
-            <th>Schedule</th>
13
-            <th>Last Check</th>
14
-            <th>Last Event Out</th>
15
-            <th>Last Event In</th>
16
-            <th>Events Created</th>
17
-            <th>Working?</th>
18
-            <th></th>
19
-          </tr>
20
-
21
-          <% @agents.each do |agent| %>
22
-            <tr>
23
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
24
-                <%= link_to agent.name, agent_path(agent) %>
25
-                <br/>
26
-                <span class='text-muted'><%= agent.short_type.titleize %></span>
27
-              </td>
28
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
29
-                <% if agent.can_be_scheduled? %>
30
-                  <%= agent.schedule.to_s.humanize.titleize %>
31
-                <% else %>
32
-                  <span class='not-applicable'></span>
33
-                <% end %>
34
-              </td>
35
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
36
-                <% if agent.can_be_scheduled? %>
37
-                  <%= agent.last_check_at ? time_ago_in_words(agent.last_check_at) + " ago" : "never" %>
38
-                <% else %>
39
-                  <span class='not-applicable'></span>
40
-                <% end %>
41
-              </td>
42
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
43
-                <% if agent.can_create_events? %>
44
-                  <%= agent.last_event_at ? time_ago_in_words(agent.last_event_at) + " ago" : "never" %>
45
-                <% else %>
46
-                  <span class='not-applicable'></span>
47
-                <% end %>
48
-              </td>
49
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
50
-                <% if agent.can_receive_events? %>
51
-                  <%= agent.last_receive_at ? time_ago_in_words(agent.last_receive_at) + " ago" : "never" %>
52
-                <% else %>
53
-                  <span class='not-applicable'></span>
54
-                <% end %>
55
-              </td>
56
-              <td class='<%= "agent-disabled" if agent.disabled? %>'>
57
-                <% if agent.can_create_events? %>
58
-                  <%= link_to(agent.events_count || 0, events_path(:agent => agent.to_param)) %>
59
-                <% else %>
60
-                  <span class='not-applicable'></span>
61
-                <% end %>
62
-              </td>
63
-              <td><%= working(agent) %></td>
64
-              <td>
65
-                <div class="btn-group">
66
-                  <button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
67
-                    <span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span>
68
-                  </button>
69
-                  <%= render 'action_menu', :agent => agent, :returnTo => "index" %>
70
-                </div>
71
-              </td>
72
-            </tr>
73
-          <% end %>
74
-        </table>
75
-      </div>
76
-
77
-      <%= paginate @agents, :theme => 'twitter-bootstrap-3' %>
8
+      <%= render 'agents/table' %>
78 9
 
79 10
       <br/>
80 11
 

+ 1 - 1
app/views/agents/show.html.erb

@@ -22,7 +22,7 @@
22 22
 
23 23
           <li class="dropdown">
24 24
             <a class="dropdown-toggle" data-toggle="dropdown" href="#"><span class="glyphicon glyphicon-th-list"></span> Actions <span class="caret"></span></a>
25
-            <%= render 'action_menu', :agent => @agent, :returnTo => "show" %>
25
+            <%= render 'agents/action_menu', :agent => @agent, :returnTo => "show" %>
26 26
           </li>
27 27
         </ul>
28 28
       </div>

+ 2 - 2
app/views/events/index.html.erb

@@ -20,13 +20,13 @@
20 20
           <% next unless event.agent %>
21 21
           <tr>
22 22
             <td><%= link_to event.agent.name, agent_path(event.agent) %></td>
23
-            <td><%= time_ago_in_words event.created_at %> ago</td>
23
+            <td title='<%= event.created_at %>'><%= time_ago_in_words event.created_at %> ago</td>
24 24
             <td class='payload'><%= truncate event.payload.to_json, :length => 90, :omission => "" %></td>
25 25
             <td>
26 26
               <div class="btn-group btn-group-xs">
27 27
                 <%= link_to 'Show', event_path(event), class: "btn btn-default" %>
28 28
                 <%= link_to 'Re-emit', reemit_event_path(event), method: :post, data: { confirm: 'Are you sure you want to duplicate this event and emit the new one now?' }, class: "btn btn-default" %>
29
-                <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default btn-danger" %>
29
+                <%= link_to 'Delete', event_path(event), method: :delete, data: { confirm: 'Are you sure?' }, class: "btn btn-default" %>
30 30
               </div>
31 31
             </td>
32 32
           </tr>

+ 1 - 0
app/views/layouts/_navigation.html.erb

@@ -13,6 +13,7 @@
13 13
   <% if user_signed_in? %>
14 14
     <ul class='nav navbar-nav'>
15 15
       <%= nav_link "Agents", agents_path %>
16
+      <%= nav_link "Scenarios", scenarios_path %>
16 17
       <%= nav_link "Events", events_path %>
17 18
       <%= nav_link "Credentials", user_credentials_path %>
18 19
       <%= nav_link "Services", services_path %>

+ 14 - 10
app/views/layouts/application.html.erb

@@ -31,18 +31,22 @@
31 31
 
32 32
     <script>
33 33
       var agentPaths = {};
34
-      <% if current_user -%>
35
-        var myAgents = <%= Utils.jsonify(current_user.agents.select([:name, :id, :schedule]).inject({}) {|m, a| m[a.name] = agent_path(a); m }) %>;
34
+      var agentNames = [];
35
+      <% if current_user.present? -%>
36
+        var myAgents = <%= Utils.jsonify(current_user.agents.pluck(:name, :id).inject({}) {|m, a| m[a.first] = agent_path(a.last); m }) %>;
37
+        var myScenarios = <%= Utils.jsonify(current_user.scenarios.pluck(:name, :id).inject({}) {|m, s| m[s.first + " Scenario"] = scenario_path(s.last); m }) %>;
36 38
         $.extend(agentPaths, myAgents);
39
+        $.extend(agentPaths, myScenarios);
40
+        agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
41
+        agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
42
+        agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
43
+        agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
44
+        agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_agents_path %>;
45
+        agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
46
+
47
+
48
+        $.each(agentPaths, function(name, v) { agentNames.push(name); });
37 49
       <% end -%>
38
-      agentPaths["All Agents Index"] = <%= Utils.jsonify agents_path %>;
39
-      agentPaths["New Agent"] = <%= Utils.jsonify new_agent_path %>;
40
-      agentPaths["Account"] = <%= Utils.jsonify edit_user_registration_path %>;
41
-      agentPaths["Events Index"] = <%= Utils.jsonify events_path %>;
42
-      agentPaths["View Agent Diagram"] = <%= Utils.jsonify diagram_agents_path %>;
43
-      agentPaths["Run Event Propagation"] = { url: <%= Utils.jsonify propagate_agents_path %>, method: 'POST' };
44
-      var agentNames = [];
45
-      $.each(agentPaths, function(name, v) { agentNames.push(name); });
46 50
     </script>
47 51
   </body>
48 52
 </html>

+ 31 - 0
app/views/scenario_imports/_step_one.html.erb

@@ -0,0 +1,31 @@
1
+<div class="row">
2
+  <div class="page-header">
3
+    <h2>
4
+      Import a Public Scenario
5
+    </h2>
6
+  </div>
7
+</div>
8
+
9
+<div class='row'>
10
+  <blockquote>You can import Scenarios, either from a <code>.json</code> file, or via a public
11
+    Scenario URL. When you import a Scenario, Huginn will keep track of where it came from and
12
+    later let you update it.</blockquote>
13
+</div>
14
+
15
+<div class='row'>
16
+  <div class="col-md-4">
17
+    <div class="form-group">
18
+      <%= f.label :url, 'Option 1: Provide a Public Scenario URL' %>
19
+      <%= f.text_field :url, :class => 'form-control', :placeholder => "Public Scenario URL" %>
20
+    </div>
21
+
22
+    <div class="form-group">
23
+      <%= f.label :file, 'Option 2: Upload a Scenario JSON File' %>
24
+      <%= f.file_field :file, :class => 'form-control' %>
25
+    </div>
26
+
27
+    <div class='form-actions'>
28
+      <%= f.submit "Start Import", :class => "btn btn-primary" %>
29
+    </div>
30
+  </div>
31
+</div>

+ 154 - 0
app/views/scenario_imports/_step_two.html.erb

@@ -0,0 +1,154 @@
1
+<div class="row">
2
+  <div class="col-md-12">
3
+    <% if @scenario_import.dangerous? %>
4
+      <div class="alert alert-danger">
5
+        <span class='glyphicon glyphicon-warning-sign'></span>
6
+        This Scenario contains one or more potentially dangerous Agents.
7
+        These may be able to run local commands or execute code.
8
+        Please be sure that you understand the Agent configurations before importing!
9
+      </div>
10
+    <% end %>
11
+
12
+    <% if @scenario_import.existing_scenario.present? %>
13
+      <div class="alert alert-warning">
14
+        <span class='glyphicon glyphicon-warning-sign'></span>
15
+        This Scenario already exists in your system. The import will update your existing
16
+        <span class='label label-info scenario'><%= @scenario_import.existing_scenario.name %></span> Scenario's title
17
+        and
18
+        description. Below you can customize how the individual agents get updated.
19
+      </div>
20
+    <% end %>
21
+
22
+    <div class="page-header">
23
+      <h2>
24
+        <%= @scenario_import.parsed_data["name"] %>
25
+        <span class='text-muted'>
26
+          (<%= pluralize @scenario_import.parsed_data["agents"].length, "Agent" %>;
27
+          exported <%= time_ago_in_words Time.parse(@scenario_import.parsed_data["exported_at"]) %> ago)
28
+        </span>
29
+      </h2>
30
+    </div>
31
+
32
+    <% if @scenario_import.parsed_data["description"].present? %>
33
+      <blockquote><%= @scenario_import.parsed_data["description"] %></blockquote>
34
+    <% end %>
35
+
36
+  </div>
37
+</div>
38
+
39
+<div class='agent-import-list'>
40
+  <% @scenario_import.agent_diffs.each.with_index do |agent_diff, index| %>
41
+    <div class='agent-import' data-index='<%= index %>'>
42
+
43
+      <div class='row'>
44
+        <div class='col-md-12'>
45
+          <h3>
46
+            <a href='#' data-toggle="modal" data-target="#agent_options_<%= index %>"><%= agent_diff.name.incoming %></a>
47
+            <span class='text-muted'>
48
+              (<%= agent_diff.type.incoming %><% " -- WARNING: this Agent's type has been changed.  This import will likely fail!" if agent_diff.type.requires_merge? %>)
49
+            </span>
50
+          </h3>
51
+
52
+          <% if agent_diff.agent_exists? %>
53
+            <div class="instructions">
54
+              This Agent exists in your Huginn system.
55
+
56
+              <% if agent_diff.requires_merge? %>
57
+                Here are the differences between the incoming version and the one you have now. For each field, please
58
+                select which value you'd like to keep.
59
+              <% else %>
60
+                It's already up-to-date.
61
+              <% end %>
62
+            </div>
63
+          <% end %>
64
+        </div>
65
+      </div>
66
+
67
+      <div class="modal fade" id="agent_options_<%= index %>" tabindex="-1" role="dialog" aria-labelledby="modalLabel<%= index %>" aria-hidden="true">
68
+        <div class="modal-dialog modal-lg">
69
+          <div class="modal-content">
70
+            <div class="modal-header">
71
+              <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
72
+              <h4 class="modal-title" id="modalLabel<%= index %>">Options for '<%= agent_diff.name.updated %>'</h4>
73
+            </div>
74
+            <div class="modal-body">
75
+              <pre class='options'><%= Utils.pretty_jsonify agent_diff.options.incoming %></pre>
76
+            </div>
77
+          </div>
78
+        </div>
79
+      </div>
80
+
81
+      <% agent_diff.each_field do |field, value, selection_options| %>
82
+        <div class='row'>
83
+          <div class='col-md-4'>
84
+            <div class="form-group">
85
+              <%= label_tag "scenario_import[merges][#{index}][#{field}]", field.titleize %>
86
+              <% if selection_options.present? %>
87
+                <div>
88
+                  Your current Agent's value is:
89
+                  <span class='current'><%= selection_options.find { |s| s.last.to_s == value.current.to_s }.first %></span>
90
+                </div>
91
+                <%= select_tag "scenario_import[merges][#{index}][#{field}]", options_for_select(selection_options, value.updated), :class => 'form-control' %>
92
+              <% else %>
93
+                <div>
94
+                  Your current Agent's value is: <span class='current'><%= value.current.to_s %></span>
95
+                </div>
96
+                <%= text_field_tag "scenario_import[merges][#{index}][#{field}]", value.updated, :class => 'form-control' %>
97
+              <% end %>
98
+            </div>
99
+          </div>
100
+        </div>
101
+      <% end %>
102
+
103
+      <div class='row'>
104
+        <% if agent_diff.options.requires_merge? %>
105
+          <div class='col-md-12'>
106
+            <label>Options</label>
107
+          </div>
108
+
109
+          <div class='col-md-6'>
110
+            <textarea name="scenario_import[merges][<%= index %>][options]" rows='15' class="form-control live-json-editor">
111
+              <%= Utils.pretty_jsonify(agent_diff.options.updated) %>
112
+            </textarea>
113
+          </div>
114
+
115
+          <div class='col-md-6'>
116
+            <div>
117
+              Your current options:
118
+            </div>
119
+            <pre class='options'><%= Utils.pretty_jsonify agent_diff.options.current %></pre>
120
+          </div>
121
+        <% end %>
122
+      </div>
123
+    </div>
124
+  <% end %>
125
+</div>
126
+
127
+<div class='row'>
128
+  <div class='col-md-12'>
129
+    <div class="checkbox">
130
+      <%= f.label :do_import do %>
131
+        <%= f.check_box :do_import %> I confirm that I want to import these Agents.
132
+      <% end %>
133
+    </div>
134
+
135
+    <div class='form-actions'>
136
+      <%= f.submit "Finish Import", :class => "btn btn-primary" %>
137
+    </div>
138
+  </div>
139
+</div>
140
+
141
+
142
+<script>
143
+//  $(function() {
144
+//    $('.agent-import-list .options-toggle').on('click', function (e) {
145
+//      e.preventDefault();
146
+//      $(this).siblings('.options').slideToggle()
147
+//      if ($(this).text() == "Show Options") {
148
+//        $(this).text("Hide Options");
149
+//      } else {
150
+//        $(this).text("Show Options");
151
+//      }
152
+//    });
153
+//  });
154
+</script>

+ 32 - 0
app/views/scenario_imports/new.html.erb

@@ -0,0 +1,32 @@
1
+<div class='container scenario-import'>
2
+  <div class="row">
3
+    <div class="col-md-12">
4
+      <% if @scenario_import.errors.any? %>
5
+        <div class="row well">
6
+          <h2><%= pluralize(@scenario_import.errors.count, "error") %> prohibited this Scenario from being imported:</h2>
7
+          <% @scenario_import.errors.full_messages.each do |msg| %>
8
+            <p class='text-warning'><%= msg %></p>
9
+          <% end %>
10
+        </div>
11
+      <% end %>
12
+    </div>
13
+  </div>
14
+
15
+  <%= form_for @scenario_import, :multipart => true do |f| %>
16
+    <%= f.hidden_field :data %>
17
+
18
+    <% if @scenario_import.step_one? %>
19
+      <%= render 'step_one', :f => f %>
20
+    <% elsif @scenario_import.step_two? %>
21
+      <%= render 'step_two', :f => f %>
22
+    <% end %>
23
+  <% end %>
24
+
25
+  <hr />
26
+
27
+  <div class="row">
28
+    <div class="col-md-12">
29
+      <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
30
+    </div>
31
+  </div>
32
+</div>

+ 57 - 0
app/views/scenarios/_form.html.erb

@@ -0,0 +1,57 @@
1
+<%= form_for(@scenario, :method => @scenario.new_record? ? "POST" : "PUT") do |f| %>
2
+  <% if @scenario.errors.any? %>
3
+    <div class="row well">
4
+      <h2><%= pluralize(@scenario.errors.count, "error") %> prohibited this Scenario from being saved:</h2>
5
+      <% @scenario.errors.full_messages.each do |msg| %>
6
+        <p class='text-warning'><%= msg %></p>
7
+      <% end %>
8
+    </div>
9
+  <% end %>
10
+
11
+  <div class="row">
12
+    <div class="col-md-4">
13
+      <div class="form-group">
14
+        <%= f.label :name %>
15
+        <%= f.text_field :name, :class => 'form-control', :placeholder => "Name your Scenario" %>
16
+      </div>
17
+    </div>
18
+  </div>
19
+
20
+  <div class="row">
21
+    <div class="col-md-8">
22
+      <div class="form-group">
23
+        <%= f.label :description, "Optional Description" %>
24
+        <%= f.text_area :description, :rows => 10, :class => 'form-control', :placeholder => "Optionally describe what this Scenario will do.  If this will be public, you should also include some contact information." %>
25
+      </div>
26
+
27
+      <div class="checkbox">
28
+        <%= f.label :public do %>
29
+          <%= f.check_box :public %> Share this Scenario publicly
30
+        <% end %>
31
+        <span class="glyphicon glyphicon-question-sign hover-help" data-content="When selected, this Scenario and all Agents in it will be made public.  An export URL will be available to share with other Huginn users.  Be very careful that you do not have secret credentials stored in these Agents' options.  Instead, use Credentials by reference."></span>
32
+      </div>
33
+
34
+    </div>
35
+  </div>
36
+
37
+  <div class="row">
38
+    <div class="col-md-4">
39
+      <div class="form-group">
40
+        <div>
41
+          <%= f.label :agents %>
42
+          <%= f.select(:agent_ids,
43
+                       options_for_select(current_user.agents.pluck(:name, :id), @scenario.agent_ids),
44
+                       {}, { :multiple => true, :size => 5, :class => 'select2 form-control' }) %>
45
+        </div>
46
+      </div>
47
+    </div>
48
+  </div>
49
+
50
+  <div class="row">
51
+    <div class="col-md-12">
52
+      <div class='form-actions' style='clear: both'>
53
+        <%= f.submit "Save Scenario", :class => "btn btn-primary" %>
54
+      </div>
55
+    </div>
56
+  </div>
57
+<% end %>

+ 21 - 0
app/views/scenarios/edit.html.erb

@@ -0,0 +1,21 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Edit Scenario
7
+        </h2>
8
+      </div>
9
+
10
+      <%= render 'form' %>
11
+
12
+      <hr>
13
+
14
+      <div class="row">
15
+        <div class="col-md-12">
16
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
17
+        </div>
18
+      </div>
19
+    </div>
20
+  </div>
21
+</div>

+ 50 - 0
app/views/scenarios/index.html.erb

@@ -0,0 +1,50 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Your Scenarios
7
+        </h2>
8
+      </div>
9
+
10
+      <blockquote>Scenarios are named groups of Agents.  Scenarios allow you to organize your agents,
11
+        and to import and export sets of Agents to share.</blockquote>
12
+
13
+      <table class='table table-striped'>
14
+        <tr>
15
+          <th>Name</th>
16
+          <th>Agents</th>
17
+          <th>Public</th>
18
+          <th></th>
19
+        </tr>
20
+
21
+        <% @scenarios.each do |scenario| %>
22
+          <tr>
23
+            <td>
24
+              <%= link_to(scenario.name, scenario) %>
25
+            </td>
26
+            <td><%= link_to pluralize(scenario.agents.count, "agent"), scenario %></td>
27
+            <td><%= scenario.public? ? "yes" : "no" %></td>
28
+            <td>
29
+              <div class="btn-group btn-group-xs" style="float: right">
30
+                <%= link_to 'Show', scenario, class: "btn btn-default" %>
31
+                <%= link_to 'Edit', edit_scenario_path(scenario), class: "btn btn-default" %>
32
+                <%= link_to 'Share', share_scenario_path(scenario), class: "btn btn-default" %>
33
+                <%= link_to 'Delete', scenario_path(scenario), method: :delete, data: { confirm: "This will remove the '#{scenario.name}' Scenerio from all Agents and delete it.  Are you sure?" }, class: "btn btn-default" %>
34
+              </div>
35
+            </td>
36
+          </tr>
37
+        <% end %>
38
+      </table>
39
+
40
+      <%= paginate @scenarios, :theme => 'twitter-bootstrap' %>
41
+
42
+      <br/>
43
+
44
+      <div class="btn-group">
45
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> New Scenario'.html_safe, new_scenario_path, class: "btn btn-default" %>
46
+        <%= link_to '<span class="glyphicon glyphicon-plus"></span> Import Scenario'.html_safe, new_scenario_imports_path, class: "btn btn-default" %>
47
+      </div>
48
+    </div>
49
+  </div>
50
+</div>

+ 21 - 0
app/views/scenarios/new.html.erb

@@ -0,0 +1,21 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>
6
+          Create a new Scenario
7
+        </h2>
8
+      </div>
9
+
10
+      <%= render 'form' %>
11
+
12
+      <hr>
13
+
14
+      <div class="row">
15
+        <div class="col-md-12">
16
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
17
+        </div>
18
+      </div>
19
+    </div>
20
+  </div>
21
+</div>

+ 33 - 0
app/views/scenarios/share.html.erb

@@ -0,0 +1,33 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2>Share <span class='label label-info scenario'><%= @scenario.name %></span> with the world</h2>
6
+      </div>
7
+
8
+      <p>
9
+        <strong>Please be sure that none of the Agents in this Scenario have sensitive data in their settings before sharing!</strong>
10
+      </p>
11
+
12
+      <% if @scenario.public? %>
13
+        <p>
14
+          This Scenario is public.  You can <%= link_to "download and share your export file", export_scenario_path(@scenario, :format => :json) %>, or give out this URL:
15
+        </p>
16
+
17
+        <form onsubmit='return false;'>
18
+          <input type='text' class='form-control' value='<%= export_scenario_url(@scenario, :format => :json) %>' onclick="return this.select();"/>
19
+        </form>
20
+      <% else %>
21
+        This Scenario is not public.  You can share it by <%= link_to "downloading and sharing your export file", export_scenario_path(@scenario, :format => :json) %>.
22
+      <% end %>
23
+
24
+      <hr>
25
+
26
+      <div class="row">
27
+        <div class="col-md-12">
28
+          <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenario_path(@scenario), class: "btn btn-default" %>
29
+        </div>
30
+      </div>
31
+    </div>
32
+  </div>
33
+</div>

+ 28 - 0
app/views/scenarios/show.html.erb

@@ -0,0 +1,28 @@
1
+<div class='container'>
2
+  <div class='row'>
3
+    <div class='col-md-12'>
4
+      <div class="page-header">
5
+        <h2><span class='label label-info scenario'><%= @scenario.name %></span> <%= "Public" if @scenario.public? %> Scenario</h2>
6
+      </div>
7
+
8
+      <% if @scenario.description.present? %>
9
+        <blockquote><%= @scenario.description %></blockquote>
10
+      <% end %>
11
+
12
+      <%= render 'agents/table', :returnTo => scenario_path(@scenario) %>
13
+
14
+      <br/>
15
+
16
+      <div class="btn-group">
17
+        <%= link_to '<span class="glyphicon glyphicon-chevron-left"></span> Back'.html_safe, scenarios_path, class: "btn btn-default" %>
18
+        <%= link_to '<span class="glyphicon glyphicon-random"></span> View Diagram'.html_safe, diagram_agents_path(:scenario_id => @scenario.to_param), class: "btn btn-default" %>
19
+        <%= link_to '<span class="glyphicon glyphicon-edit"></span> Edit'.html_safe, edit_scenario_path(@scenario), class: "btn btn-default" %>
20
+        <% if @scenario.source_url.present? %>
21
+          <%= link_to '<span class="glyphicon glyphicon-plus"></span> Update'.html_safe, new_scenario_imports_path(:url => @scenario.source_url), class: "btn btn-default" %>
22
+        <% end %>
23
+        <%= link_to '<span class="glyphicon glyphicon-share-alt"></span> Share'.html_safe, share_scenario_path(@scenario), class: "btn btn-default" %>
24
+        <%= link_to '<span class="glyphicon glyphicon-trash"></span> Delete'.html_safe, scenario_path(@scenario), method: :delete, data: { confirm: "This will remove the '#{@scenario.name}' Scenerio from all Agents and delete it.  Are you sure?" }, class: "btn btn-default" %>
25
+      </div>
26
+    </div>
27
+  </div>
28
+</div>

+ 12 - 0
config/routes.rb

@@ -3,6 +3,7 @@ Huginn::Application.routes.draw do
3 3
     member do
4 4
       post :run
5 5
       post :handle_details_post
6
+      put :leave_scenario
6 7
       delete :remove_events
7 8
     end
8 9
 
@@ -26,6 +27,17 @@ Huginn::Application.routes.draw do
26 27
     end
27 28
   end
28 29
 
30
+  resources :scenarios do
31
+    collection do
32
+      resource :scenario_imports, :only => [:new, :create]
33
+    end
34
+
35
+    member do
36
+      get :share
37
+      get :export
38
+    end
39
+  end
40
+
29 41
   resources :user_credentials, :except => :show
30 42
 
31 43
   resources :services, :only => [:index, :destroy] do

+ 12 - 0
db/migrate/20140509170420_create_scenarios.rb

@@ -0,0 +1,12 @@
1
+class CreateScenarios < ActiveRecord::Migration
2
+  def change
3
+    create_table :scenarios do |t|
4
+      t.string :name, :null => false
5
+      t.integer :user_id, :null => false
6
+
7
+      t.timestamps
8
+    end
9
+
10
+    add_column :users, :scenario_count, :integer, :null => false, :default => 0
11
+  end
12
+end

+ 10 - 0
db/migrate/20140509170443_create_scenario_memberships.rb

@@ -0,0 +1,10 @@
1
+class CreateScenarioMemberships < ActiveRecord::Migration
2
+  def change
3
+    create_table :scenario_memberships do |t|
4
+      t.integer :agent_id, :null => false
5
+      t.integer :scenario_id, :null => false
6
+
7
+      t.timestamps
8
+    end
9
+  end
10
+end

+ 8 - 0
db/migrate/20140531232016_add_fields_to_scenarios.rb

@@ -0,0 +1,8 @@
1
+class AddFieldsToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_column :scenarios, :description, :text
4
+    add_column :scenarios, :public, :boolean, :default => false, :null => false
5
+    add_column :scenarios, :guid, :string, :null => false
6
+    add_column :scenarios, :source_url, :string
7
+  end
8
+end

+ 7 - 0
db/migrate/20140602014917_add_indices_to_scenarios.rb

@@ -0,0 +1,7 @@
1
+class AddIndicesToScenarios < ActiveRecord::Migration
2
+  def change
3
+    add_index :scenarios, [:user_id, :guid], :unique => true
4
+    add_index :scenario_memberships, :agent_id
5
+    add_index :scenario_memberships, :scenario_id
6
+  end
7
+end

+ 15 - 0
db/migrate/20140605032822_add_guid_to_agents.rb

@@ -0,0 +1,15 @@
1
+class AddGuidToAgents < ActiveRecord::Migration
2
+  class Agent < ActiveRecord::Base; end
3
+
4
+  def change
5
+    add_column :agents, :guid, :string
6
+
7
+    Agent.find_each do |agent|
8
+      agent.update_attribute :guid, SecureRandom.hex
9
+    end
10
+
11
+    change_column_null :agents, :guid, false
12
+
13
+    add_index :agents, :guid
14
+  end
15
+end

+ 27 - 1
db/schema.rb

@@ -11,7 +11,7 @@
11 11
 #
12 12
 # It's strongly recommended that you check this file into your version control system.
13 13
 
14
-ActiveRecord::Schema.define(version: 20140525150140) do
14
+ActiveRecord::Schema.define(version: 20140605032822) do
15 15
 
16 16
   # These are extensions that must be enabled in order to support this database
17 17
   enable_extension "plpgsql"
@@ -46,8 +46,10 @@ ActiveRecord::Schema.define(version: 20140525150140) do
46 46
     t.boolean  "propagate_immediately", default: false, null: false
47 47
     t.boolean  "disabled",              default: false, null: false
48 48
     t.integer  "service_id"
49
+    t.string   "guid",                                  null: false
49 50
   end
50 51
 
52
+  add_index "agents", ["guid"], name: "index_agents_on_guid", using: :btree
51 53
   add_index "agents", ["schedule"], name: "index_agents_on_schedule", using: :btree
52 54
   add_index "agents", ["type"], name: "index_agents_on_type", using: :btree
53 55
   add_index "agents", ["user_id", "created_at"], name: "index_agents_on_user_id_and_created_at", using: :btree
@@ -94,6 +96,29 @@ ActiveRecord::Schema.define(version: 20140525150140) do
94 96
   add_index "links", ["receiver_id", "source_id"], name: "index_links_on_receiver_id_and_source_id", using: :btree
95 97
   add_index "links", ["source_id", "receiver_id"], name: "index_links_on_source_id_and_receiver_id", using: :btree
96 98
 
99
+  create_table "scenario_memberships", force: true do |t|
100
+    t.integer  "agent_id",    null: false
101
+    t.integer  "scenario_id", null: false
102
+    t.datetime "created_at"
103
+    t.datetime "updated_at"
104
+  end
105
+
106
+  add_index "scenario_memberships", ["agent_id"], name: "index_scenario_memberships_on_agent_id", using: :btree
107
+  add_index "scenario_memberships", ["scenario_id"], name: "index_scenario_memberships_on_scenario_id", using: :btree
108
+
109
+  create_table "scenarios", force: true do |t|
110
+    t.string   "name",                        null: false
111
+    t.integer  "user_id",                     null: false
112
+    t.datetime "created_at"
113
+    t.datetime "updated_at"
114
+    t.text     "description"
115
+    t.boolean  "public",      default: false, null: false
116
+    t.string   "guid",                        null: false
117
+    t.string   "source_url"
118
+  end
119
+
120
+  add_index "scenarios", ["user_id", "guid"], name: "index_scenarios_on_user_id_and_guid", unique: true, using: :btree
121
+
97 122
   create_table "services", force: true do |t|
98 123
     t.integer  "user_id"
99 124
     t.string   "provider"
@@ -141,6 +166,7 @@ ActiveRecord::Schema.define(version: 20140525150140) do
141 166
     t.datetime "locked_at"
142 167
     t.string   "username",                               null: false
143 168
     t.string   "invitation_code",                        null: false
169
+    t.integer  "scenario_count",         default: 0,     null: false
144 170
   end
145 171
 
146 172
   add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree

+ 54 - 0
lib/agents_exporter.rb

@@ -0,0 +1,54 @@
1
+class AgentsExporter
2
+  attr_accessor :options
3
+
4
+  def initialize(options)
5
+    self.options = options
6
+  end
7
+
8
+  # Filename should have no commas or special characters to support Content-Disposition on older browsers.
9
+  def filename
10
+    ((options[:name] || '').downcase.gsub(/[^a-z0-9_-]/, '-').gsub(/-+/, '-').gsub(/^-|-$/, '').presence || 'exported-agents') + ".json"
11
+  end
12
+
13
+  def as_json(opts = {})
14
+    {
15
+      :name => options[:name].presence || 'No name provided',
16
+      :description => options[:description].presence || 'No description provided',
17
+      :source_url => options[:source_url],
18
+      :guid => options[:guid],
19
+      :exported_at => Time.now.utc.iso8601,
20
+      :agents => agents.map { |agent| agent_as_json(agent) },
21
+      :links => links
22
+    }
23
+  end
24
+
25
+  def agents
26
+    options[:agents].to_a
27
+  end
28
+
29
+  def links
30
+    agent_ids = agents.map(&:id)
31
+
32
+    contained_links = agents.map.with_index do |agent, index|
33
+      agent.links_as_source.where(:receiver_id => agent_ids).map do |link|
34
+        { :source => index, :receiver => agent_ids.index(link.receiver_id) }
35
+      end
36
+    end
37
+
38
+    contained_links.flatten.compact
39
+  end
40
+
41
+  def agent_as_json(agent)
42
+    {
43
+      :type => agent.type,
44
+      :name => agent.name,
45
+      :disabled => agent.disabled,
46
+      :guid => agent.guid,
47
+      :options => agent.options
48
+    }.tap do |options|
49
+      options[:schedule] = agent.schedule if agent.can_be_scheduled?
50
+      options[:keep_events_for] = agent.keep_events_for if agent.can_create_events?
51
+      options[:propagate_immediately] = agent.propagate_immediately if agent.can_receive_events?
52
+    end
53
+  end
54
+end

spec/lib/inheritance_tracking_spec.rb → spec/concerns/inheritance_tracking_spec.rb


+ 103 - 0
spec/controllers/agents_controller_spec.rb

@@ -34,6 +34,47 @@ describe AgentsController do
34 34
     end
35 35
   end
36 36
 
37
+  describe "POST run" do
38
+    it "triggers Agent.async_check with the Agent's ID" do
39
+      sign_in users(:bob)
40
+      mock(Agent).async_check(agents(:bob_manual_event_agent).id)
41
+      post :run, :id => agents(:bob_manual_event_agent).to_param
42
+    end
43
+
44
+    it "can only be accessed by the Agent's owner" do
45
+      sign_in users(:jane)
46
+      lambda {
47
+        post :run, :id => agents(:bob_manual_event_agent).to_param
48
+      }.should raise_error(ActiveRecord::RecordNotFound)
49
+    end
50
+  end
51
+
52
+  describe "POST remove_events" do
53
+    it "deletes all events created by the given Agent" do
54
+      sign_in users(:bob)
55
+      agent_event = events(:bob_website_agent_event).id
56
+      other_event = events(:jane_website_agent_event).id
57
+      post :remove_events, :id => agents(:bob_website_agent).to_param
58
+      Event.where(:id => agent_event).count.should == 0
59
+      Event.where(:id => other_event).count.should == 1
60
+    end
61
+
62
+    it "can only be accessed by the Agent's owner" do
63
+      sign_in users(:jane)
64
+      lambda {
65
+        post :remove_events, :id => agents(:bob_website_agent).to_param
66
+      }.should raise_error(ActiveRecord::RecordNotFound)
67
+    end
68
+  end
69
+
70
+  describe "POST propagate" do
71
+    it "runs event propagation for all Agents" do
72
+      sign_in users(:bob)
73
+      mock.proxy(Agent).receive!
74
+      post :propagate
75
+    end
76
+  end
77
+
37 78
   describe "GET show" do
38 79
     it "only shows Agents for the current user" do
39 80
       sign_in users(:bob)
@@ -152,18 +193,80 @@ describe AgentsController do
152 193
       }.should raise_error(ActiveRecord::RecordNotFound)
153 194
     end
154 195
 
196
+    it "accepts JSON requests" do
197
+      sign_in users(:bob)
198
+      post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :format => :json
199
+      agents(:bob_website_agent).reload.name.should == "New name"
200
+      JSON.parse(response.body)['name'].should == "New name"
201
+      response.should be_success
202
+    end
203
+
155 204
     it "will not accept Agent sources owned by other users" do
156 205
       sign_in users(:bob)
157 206
       post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:source_ids => [agents(:jane_weather_agent).id])
158 207
       assigns(:agent).should have(1).errors_on(:sources)
159 208
     end
160 209
 
210
+    it "will not accept Scenarios owned by other users" do
211
+      sign_in users(:bob)
212
+      post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:scenario_ids => [scenarios(:jane_weather).id])
213
+      assigns(:agent).should have(1).errors_on(:scenarios)
214
+    end
215
+
161 216
     it "shows errors" do
162 217
       sign_in users(:bob)
163 218
       post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "")
164 219
       assigns(:agent).should have(1).errors_on(:name)
165 220
       response.should render_template("edit")
166 221
     end
222
+
223
+    describe "redirecting back" do
224
+      before do
225
+        sign_in users(:bob)
226
+      end
227
+
228
+      it "can redirect back to the show path" do
229
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "show"
230
+        response.should redirect_to(agent_path(agents(:bob_website_agent)))
231
+      end
232
+
233
+      it "redirect back to the index path by default" do
234
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name")
235
+        response.should redirect_to(agents_path)
236
+      end
237
+
238
+      it "accepts return paths to scenarios" do
239
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "/scenarios/2"
240
+        response.should redirect_to("/scenarios/2")
241
+      end
242
+
243
+      it "sanitizes return paths" do
244
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "/scenar"
245
+        response.should redirect_to(agents_path)
246
+
247
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "http://google.com"
248
+        response.should redirect_to(agents_path)
249
+
250
+        post :update, :id => agents(:bob_website_agent).to_param, :agent => valid_attributes(:name => "New name"), :return => "javascript:alert(1)"
251
+        response.should redirect_to(agents_path)
252
+      end
253
+    end
254
+  end
255
+
256
+  describe "PUT leave_scenario" do
257
+    it "removes an Agent from the given Scenario for the current user" do
258
+      sign_in users(:bob)
259
+
260
+      agents(:bob_weather_agent).scenarios.should include(scenarios(:bob_weather))
261
+      put :leave_scenario, :id => agents(:bob_weather_agent).to_param, :scenario_id => scenarios(:bob_weather).to_param
262
+      agents(:bob_weather_agent).scenarios.should_not include(scenarios(:bob_weather))
263
+
264
+      Scenario.where(:id => scenarios(:bob_weather).id).should exist
265
+
266
+      lambda {
267
+        put :leave_scenario, :id => agents(:jane_weather_agent).to_param, :scenario_id => scenarios(:jane_weather).to_param
268
+      }.should raise_error(ActiveRecord::RecordNotFound)
269
+    end
167 270
   end
168 271
 
169 272
   describe "DELETE destroy" do

+ 26 - 0
spec/controllers/scenario_imports_controller_spec.rb

@@ -0,0 +1,26 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioImportsController do
4
+  before do
5
+    sign_in users(:bob)
6
+  end
7
+
8
+  describe "GET new" do
9
+    it "initializes a new ScenarioImport and renders new" do
10
+      get :new
11
+      assigns(:scenario_import).should be_a(ScenarioImport)
12
+      response.should render_template(:new)
13
+    end
14
+  end
15
+
16
+  describe "POST create" do
17
+    it "initializes a ScenarioImport for current_user, passing in params" do
18
+      post :create, :scenario_import => { :url => "bad url" }
19
+      assigns(:scenario_import).user.should == users(:bob)
20
+      assigns(:scenario_import).url.should == "bad url"
21
+      assigns(:scenario_import).should_not be_valid
22
+      response.should render_template(:new)
23
+    end
24
+  end
25
+end
26
+

+ 152 - 0
spec/controllers/scenarios_controller_spec.rb

@@ -0,0 +1,152 @@
1
+require 'spec_helper'
2
+
3
+describe ScenariosController do
4
+  def valid_attributes(options = {})
5
+    { :name => "some_name" }.merge(options)
6
+  end
7
+
8
+  before do
9
+    sign_in users(:bob)
10
+  end
11
+
12
+  describe "GET index" do
13
+    it "only returns Scenarios for the current user" do
14
+      get :index
15
+      assigns(:scenarios).all? {|i| i.user.should == users(:bob) }.should be_true
16
+    end
17
+  end
18
+
19
+  describe "GET show" do
20
+    it "only shows Scenarios for the current user" do
21
+      get :show, :id => scenarios(:bob_weather).to_param
22
+      assigns(:scenario).should eq(scenarios(:bob_weather))
23
+
24
+      lambda {
25
+        get :show, :id => scenarios(:jane_weather).to_param
26
+      }.should raise_error(ActiveRecord::RecordNotFound)
27
+    end
28
+
29
+    it "loads Agents for the requested Scenario" do
30
+      get :show, :id => scenarios(:bob_weather).to_param
31
+      assigns(:agents).pluck(:id).should eq(scenarios(:bob_weather).agents.pluck(:id))
32
+    end
33
+  end
34
+
35
+  describe "GET share" do
36
+    it "only displays Scenario share information for the current user" do
37
+      get :share, :id => scenarios(:bob_weather).to_param
38
+      assigns(:scenario).should eq(scenarios(:bob_weather))
39
+
40
+      lambda {
41
+        get :share, :id => scenarios(:jane_weather).to_param
42
+      }.should raise_error(ActiveRecord::RecordNotFound)
43
+    end
44
+  end
45
+
46
+  describe "GET export" do
47
+    it "returns a JSON file download from an instantiated AgentsExporter" do
48
+      get :export, :id => scenarios(:bob_weather).to_param
49
+      assigns(:exporter).options[:name].should == scenarios(:bob_weather).name
50
+      assigns(:exporter).options[:description].should == scenarios(:bob_weather).description
51
+      assigns(:exporter).options[:agents].should == scenarios(:bob_weather).agents
52
+      assigns(:exporter).options[:guid].should == scenarios(:bob_weather).guid
53
+      assigns(:exporter).options[:source_url].should be_false
54
+      response.headers['Content-Disposition'].should == 'attachment; filename="bob-s-weather-alert-scenario.json"'
55
+      response.headers['Content-Type'].should == 'application/json; charset=utf-8'
56
+      JSON.parse(response.body)["name"].should == scenarios(:bob_weather).name
57
+    end
58
+
59
+    it "only exports private Scenarios for the current user" do
60
+      get :export, :id => scenarios(:bob_weather).to_param
61
+      assigns(:scenario).should eq(scenarios(:bob_weather))
62
+
63
+      lambda {
64
+        get :export, :id => scenarios(:jane_weather).to_param
65
+      }.should raise_error(ActiveRecord::RecordNotFound)
66
+    end
67
+
68
+    describe "public exports" do
69
+      before do
70
+        scenarios(:jane_weather).update_attribute :public, true
71
+      end
72
+
73
+      it "exports public scenarios for other users when logged in" do
74
+        get :export, :id => scenarios(:jane_weather).to_param
75
+        assigns(:scenario).should eq(scenarios(:jane_weather))
76
+        assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
77
+      end
78
+
79
+      it "exports public scenarios for other users when logged out" do
80
+        sign_out :user
81
+        get :export, :id => scenarios(:jane_weather).to_param
82
+        assigns(:scenario).should eq(scenarios(:jane_weather))
83
+        assigns(:exporter).options[:source_url].should == export_scenario_url(scenarios(:jane_weather))
84
+      end
85
+    end
86
+  end
87
+
88
+  describe "GET edit" do
89
+    it "only shows Scenarios for the current user" do
90
+      get :edit, :id => scenarios(:bob_weather).to_param
91
+      assigns(:scenario).should eq(scenarios(:bob_weather))
92
+
93
+      lambda {
94
+        get :edit, :id => scenarios(:jane_weather).to_param
95
+      }.should raise_error(ActiveRecord::RecordNotFound)
96
+    end
97
+  end
98
+
99
+  describe "POST create" do
100
+    it "creates Scenarios for the current user" do
101
+      expect {
102
+        post :create, :scenario => valid_attributes
103
+      }.to change { users(:bob).scenarios.count }.by(1)
104
+    end
105
+
106
+    it "shows errors" do
107
+      expect {
108
+        post :create, :scenario => valid_attributes(:name => "")
109
+      }.not_to change { users(:bob).scenarios.count }
110
+      assigns(:scenario).should have(1).errors_on(:name)
111
+      response.should render_template("new")
112
+    end
113
+
114
+    it "will not create Scenarios for other users" do
115
+      expect {
116
+        post :create, :scenario => valid_attributes(:user_id => users(:jane).id)
117
+      }.to raise_error(ActiveModel::MassAssignmentSecurity::Error)
118
+    end
119
+  end
120
+
121
+  describe "PUT update" do
122
+    it "updates attributes on Scenarios for the current user" do
123
+      post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "new_name", :public => "1" }
124
+      response.should redirect_to(scenario_path(scenarios(:bob_weather)))
125
+      scenarios(:bob_weather).reload.name.should == "new_name"
126
+      scenarios(:bob_weather).should be_public
127
+
128
+      lambda {
129
+        post :update, :id => scenarios(:jane_weather).to_param, :scenario => { :name => "new_name" }
130
+      }.should raise_error(ActiveRecord::RecordNotFound)
131
+      scenarios(:jane_weather).reload.name.should_not == "new_name"
132
+    end
133
+
134
+    it "shows errors" do
135
+      post :update, :id => scenarios(:bob_weather).to_param, :scenario => { :name => "" }
136
+      assigns(:scenario).should have(1).errors_on(:name)
137
+      response.should render_template("edit")
138
+    end
139
+  end
140
+
141
+  describe "DELETE destroy" do
142
+    it "destroys only Scenarios owned by the current user" do
143
+      expect {
144
+        delete :destroy, :id => scenarios(:bob_weather).to_param
145
+      }.to change(Scenario, :count).by(-1)
146
+
147
+      lambda {
148
+        delete :destroy, :id => scenarios(:jane_weather).to_param
149
+      }.should raise_error(ActiveRecord::RecordNotFound)
150
+    end
151
+  end
152
+end

+ 10 - 1
spec/fixtures/agents.yml

@@ -4,6 +4,7 @@ jane_website_agent:
4 4
   events_count: 1
5 5
   schedule: "5pm"
6 6
   name: "ZKCD"
7
+  guid: <%= SecureRandom.hex %>
7 8
   options: <%= {
8 9
                  :url => "http://trailers.apple.com/trailers/home/rss/newtrailers.rss",
9 10
                  :expected_update_period_in_days => 2,
@@ -20,6 +21,7 @@ bob_website_agent:
20 21
   events_count: 1
21 22
   schedule: "midnight"
22 23
   name: "ZKCD"
24
+  guid: <%= SecureRandom.hex %>
23 25
   options: <%= {
24 26
                  :url => "http://xkcd.com",
25 27
                  :expected_update_period_in_days => 2,
@@ -35,6 +37,7 @@ bob_weather_agent:
35 37
   user: bob
36 38
   schedule: "midnight"
37 39
   name: "SF Weather"
40
+  guid: <%= SecureRandom.hex %>
38 41
   keep_events_for: 45
39 42
   options: <%= {
40 43
                  :location => 94102,
@@ -48,6 +51,7 @@ jane_weather_agent:
48 51
   user: jane
49 52
   schedule: "midnight"
50 53
   name: "SF Weather"
54
+  guid: <%= SecureRandom.hex %>
51 55
   keep_events_for: 30
52 56
   options: <%= {
53 57
                  :location => 94103,
@@ -60,6 +64,7 @@ jane_rain_notifier_agent:
60 64
   type: Agents::TriggerAgent
61 65
   user: jane
62 66
   name: "Jane's Rain Watcher"
67
+  guid: <%= SecureRandom.hex %>
63 68
   options: <%= {
64 69
                  :expected_receive_period_in_days => "2",
65 70
                  :rules => [{
@@ -74,6 +79,7 @@ bob_rain_notifier_agent:
74 79
   type: Agents::TriggerAgent
75 80
   user: bob
76 81
   name: "Bob's Rain Watcher"
82
+  guid: <%= SecureRandom.hex %>
77 83
   options: <%= {
78 84
                  :expected_receive_period_in_days => "2",
79 85
                  :rules => [{
@@ -88,6 +94,7 @@ bob_twitter_user_agent:
88 94
   type: Agents::TwitterUserAgent
89 95
   user: bob
90 96
   name: "Bob's Twitter User Watcher"
97
+  guid: <%= SecureRandom.hex %>
91 98
   options: <%= {
92 99
       :username => "tectonic",
93 100
       :expected_update_period_in_days => "2",
@@ -101,8 +108,10 @@ bob_manual_event_agent:
101 108
   type: Agents::ManualEventAgent
102 109
   user: bob
103 110
   name: "Bob's event testing agent"
111
+  guid: <%= SecureRandom.hex %>
104 112
 
105 113
 bob_basecamp_agent:
106 114
   type: Agents::BasecampAgent
107 115
   user: bob
108
-  service: generic
116
+  service: generic
117
+  guid: <%= SecureRandom.hex %>

+ 15 - 0
spec/fixtures/scenario_memberships.yml

@@ -0,0 +1,15 @@
1
+jane_weather_agent_scenario_membership:
2
+  agent: jane_weather_agent
3
+  scenario: jane_weather
4
+
5
+jane_rain_notifier_agent_scenario_membership:
6
+  agent: jane_rain_notifier_agent
7
+  scenario: jane_weather
8
+
9
+bob_weather_agent_scenario_membership:
10
+  agent: bob_weather_agent
11
+  scenario: bob_weather
12
+
13
+bob_rain_notifier_agent_scenario_membership:
14
+  agent: bob_rain_notifier_agent
15
+  scenario: bob_weather

+ 13 - 0
spec/fixtures/scenarios.yml

@@ -0,0 +1,13 @@
1
+jane_weather:
2
+  name: Jane's weather alert Scenario
3
+  user: jane
4
+  description: Jane's weather alert system
5
+  public: false
6
+  guid: random-guid-generated-by-bob
7
+
8
+bob_weather:
9
+  name: Bob's weather alert Scenario
10
+  user: bob
11
+  description: Bob's weather alert system
12
+  public: false
13
+  guid: random-guid-generated-by-jane

+ 3 - 1
spec/fixtures/users.yml

@@ -4,8 +4,10 @@ bob:
4 4
   email: "bob@example.com"
5 5
   username: bob
6 6
   invitation_code: <%= User::INVITATION_CODES.last %>
7
+  scenario_count: 1
7 8
 
8 9
 jane:
9 10
   email: "jane@example.com"
10 11
   username: jane
11
-  invitation_code: <%= User::INVITATION_CODES.last %>
12
+  invitation_code: <%= User::INVITATION_CODES.last %>
13
+  scenario_count: 1

+ 61 - 0
spec/lib/agents_exporter_spec.rb

@@ -0,0 +1,61 @@
1
+# encoding: utf-8
2
+
3
+require 'spec_helper'
4
+
5
+describe AgentsExporter do
6
+  describe "#as_json" do
7
+    let(:name) { "My set of Agents" }
8
+    let(:description) { "These Agents work together nicely!" }
9
+    let(:guid) { "some-guid" }
10
+    let(:source_url) { "http://yourhuginn.com/scenarios/2/export.json" }
11
+    let(:agent_list) { [agents(:jane_weather_agent), agents(:jane_rain_notifier_agent)] }
12
+    let(:exporter) { AgentsExporter.new(:agents => agent_list, :name => name, :description => description, :source_url => source_url, :guid => guid) }
13
+
14
+    it "outputs a structure containing name, description, the date, all agents & their links" do
15
+      data = exporter.as_json
16
+      data[:name].should == name
17
+      data[:description].should == description
18
+      data[:source_url].should == source_url
19
+      data[:guid].should == guid
20
+      Time.parse(data[:exported_at]).should be_within(2).of(Time.now.utc)
21
+      data[:links].should == [{ :source => 0, :receiver => 1 }]
22
+      data[:agents].should == agent_list.map { |agent| exporter.agent_as_json(agent) }
23
+      data[:agents].all? { |agent_json| agent_json[:guid].present? && agent_json[:type].present? && agent_json[:name].present? }.should be_true
24
+
25
+      data[:agents][0].should_not have_key(:propagate_immediately) # can't receive events
26
+      data[:agents][1].should_not have_key(:schedule) # can't be scheduled
27
+    end
28
+
29
+    it "does not output links to other agents outside of the incoming set" do
30
+      Link.create!(:source_id => agents(:jane_weather_agent).id, :receiver_id => agents(:jane_website_agent).id)
31
+      Link.create!(:source_id => agents(:jane_website_agent).id, :receiver_id => agents(:jane_rain_notifier_agent).id)
32
+
33
+      exporter.as_json[:links].should == [{ :source => 0, :receiver => 1 }]
34
+    end
35
+  end
36
+
37
+  describe "#filename" do
38
+    it "strips special characters" do
39
+      AgentsExporter.new(:name => "ƏfooƐƕƺbar").filename.should == "foo-bar.json"
40
+    end
41
+
42
+    it "strips punctuation" do
43
+      AgentsExporter.new(:name => "foo,bar").filename.should == "foo-bar.json"
44
+    end
45
+
46
+    it "strips leading and trailing dashes" do
47
+      AgentsExporter.new(:name => ",foo,").filename.should == "foo.json"
48
+    end
49
+
50
+    it "has a default when options[:name] is nil" do
51
+      AgentsExporter.new(:name => nil).filename.should == "exported-agents.json"
52
+    end
53
+
54
+    it "has a default when the result is empty" do
55
+      AgentsExporter.new(:name => "").filename.should == "exported-agents.json"
56
+      AgentsExporter.new(:name => "Ə").filename.should == "exported-agents.json"
57
+      AgentsExporter.new(:name => "-").filename.should == "exported-agents.json"
58
+      AgentsExporter.new(:name => ",,").filename.should == "exported-agents.json"
59
+    end
60
+  end
61
+end

+ 28 - 1
spec/models/agent_spec.rb

@@ -1,5 +1,4 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/working_helpers'
3 2
 
4 3
 describe Agent do
5 4
   it_behaves_like WorkingHelpers
@@ -122,6 +121,17 @@ describe Agent do
122 121
       stub(Agents::CannotBeScheduled).valid_type?("Agents::CannotBeScheduled") { true }
123 122
     end
124 123
 
124
+    describe Agents::SomethingSource do
125
+      let(:new_instance) do
126
+        agent = Agents::SomethingSource.new(:name => "some agent")
127
+        agent.user = users(:bob)
128
+        agent
129
+      end
130
+
131
+      it_behaves_like LiquidInterpolatable
132
+      it_behaves_like HasGuid
133
+    end
134
+
125 135
     describe ".default_schedule" do
126 136
       it "stores the default on the class" do
127 137
         Agents::SomethingSource.default_schedule.should == "2pm"
@@ -480,6 +490,23 @@ describe Agent do
480 490
         agent.should have(0).errors_on(:sources)
481 491
       end
482 492
 
493
+      it "should not allow scenarios owned by other people" do
494
+        agent = Agents::SomethingSource.new(:name => "something")
495
+        agent.user = users(:bob)
496
+
497
+        agent.scenario_ids = [scenarios(:bob_weather).id]
498
+        agent.should have(0).errors_on(:scenarios)
499
+
500
+        agent.scenario_ids = [scenarios(:bob_weather).id, scenarios(:jane_weather).id]
501
+        agent.should have(1).errors_on(:scenarios)
502
+
503
+        agent.scenario_ids = [scenarios(:jane_weather).id]
504
+        agent.should have(1).errors_on(:scenarios)
505
+
506
+        agent.user = users(:jane)
507
+        agent.should have(0).errors_on(:scenarios)
508
+      end
509
+
483 510
       it "validates keep_events_for" do
484 511
         agent = Agents::SomethingSource.new(:name => "something")
485 512
         agent.user = users(:bob)

+ 0 - 3
spec/models/agents/data_output_agent_spec.rb

@@ -1,11 +1,8 @@
1 1
 # encoding: utf-8
2 2
 
3 3
 require 'spec_helper'
4
-require 'models/concerns/liquid_interpolatable'
5 4
 
6 5
 describe Agents::DataOutputAgent do
7
-  it_behaves_like LiquidInterpolatable
8
-
9 6
   let(:agent) do
10 7
     _agent = Agents::DataOutputAgent.new(:name => 'My Data Output Agent')
11 8
     _agent.options = _agent.default_options.merge('secrets' => ['secret1', 'secret2'], 'events_to_show' => 2)

+ 0 - 3
spec/models/agents/hipchat_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::HipchatAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   before(:each) do
8 5
     @valid_params = {
9 6
                       'auth_token' => 'token',

+ 0 - 3
spec/models/agents/human_task_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::HumanTaskAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   before do
8 5
     @checker = Agents::HumanTaskAgent.new(:name => "my human task agent")
9 6
     @checker.options = @checker.default_options

+ 0 - 3
spec/models/agents/jabber_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::JabberAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   let(:sent) { [] }
8 5
   let(:config) {
9 6
     {

+ 0 - 3
spec/models/agents/peak_detector_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::PeakDetectorAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   before do
8 5
     @valid_params = {
9 6
         'name' => "my peak detector agent",

+ 0 - 3
spec/models/agents/pushbullet_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::PushbulletAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   before(:each) do
8 5
     @valid_params = {
9 6
                       'api_key' => 'token',

+ 4 - 3
spec/models/agents/shell_command_agent_spec.rb

@@ -17,7 +17,7 @@ describe Agents::ShellCommandAgent do
17 17
     @event = Event.new
18 18
     @event.agent = agents(:jane_weather_agent)
19 19
     @event.payload = {
20
-      :command => "ls"
20
+      :cmd => "ls"
21 21
     }
22 22
     @event.save!
23 23
 
@@ -78,13 +78,14 @@ describe Agents::ShellCommandAgent do
78 78
 
79 79
   describe "#receive" do
80 80
     before do
81
-      stub(@checker).run_command(@valid_path, @event.payload[:command]) { ["fake ls output", "", 0] }
81
+      stub(@checker).run_command(@valid_path, @event.payload[:cmd]) { ["fake ls output", "", 0] }
82 82
     end
83 83
 
84 84
     it "creates events" do
85
+      @checker.options[:command] = "{{cmd}}"
85 86
       @checker.receive([@event])
86 87
       Event.last.payload[:path].should == @valid_path
87
-      Event.last.payload[:command].should == @event.payload[:command]
88
+      Event.last.payload[:command].should == @event.payload[:cmd]
88 89
       Event.last.payload[:output].should == "fake ls output"
89 90
     end
90 91
 

+ 2 - 4
spec/models/agents/slack_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::SlackAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   before(:each) do
8 5
     @valid_params = {
9 6
                       'auth_token' => 'token',
@@ -51,7 +48,8 @@ describe Agents::SlackAgent do
51 48
                        username: @event.payload[:username]
52 49
                       )
53 50
       end
54
-      expect(@checker.receive([@event])).to_not raise_error
51
+
52
+      lambda { @checker.receive([@event]) }.should_not raise_error
55 53
     end
56 54
   end
57 55
 

+ 0 - 4
spec/models/agents/translation_agent_spec.rb

@@ -1,10 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3
-
4 2
 
5 3
 describe Agents::TranslationAgent do
6
-    it_behaves_like LiquidInterpolatable
7
-
8 4
     before do
9 5
         @valid_params = {
10 6
             :name    => "somename",

+ 0 - 3
spec/models/agents/trigger_agent_spec.rb

@@ -1,9 +1,6 @@
1 1
 require 'spec_helper'
2
-require 'models/concerns/liquid_interpolatable'
3 2
 
4 3
 describe Agents::TriggerAgent do
5
-  it_behaves_like LiquidInterpolatable
6
-
7 4
   before do
8 5
     @valid_params = {
9 6
       'name' => "my trigger agent",

+ 411 - 0
spec/models/scenario_import_spec.rb

@@ -0,0 +1,411 @@
1
+require 'spec_helper'
2
+
3
+describe ScenarioImport do
4
+  let(:user) { users(:bob) }
5
+  let(:guid) { "somescenarioguid" }
6
+  let(:description) { "This is a cool Huginn Scenario that does something useful!" }
7
+  let(:name) { "A useful Scenario" }
8
+  let(:source_url) { "http://example.com/scenarios/2/export.json" }
9
+  let(:weather_agent_options) {
10
+    {
11
+      'api_key' => 'some-api-key',
12
+      'location' => '12345'
13
+    }
14
+  }
15
+  let(:trigger_agent_options) {
16
+    {
17
+      'expected_receive_period_in_days' => 2,
18
+      'rules' => [{
19
+                    'type' => "regex",
20
+                    'value' => "rain|storm",
21
+                    'path' => "conditions",
22
+                  }],
23
+      'message' => "Looks like rain!"
24
+    }
25
+  }
26
+  let(:valid_parsed_weather_agent_data) do
27
+    {
28
+      :type => "Agents::WeatherAgent",
29
+      :name => "a weather agent",
30
+      :schedule => "5pm",
31
+      :keep_events_for => 14,
32
+      :disabled => true,
33
+      :guid => "a-weather-agent",
34
+      :options => weather_agent_options
35
+    }
36
+  end
37
+  let(:valid_parsed_trigger_agent_data) do
38
+    {
39
+      :type => "Agents::TriggerAgent",
40
+      :name => "listen for weather",
41
+      :keep_events_for => 0,
42
+      :propagate_immediately => true,
43
+      :disabled => false,
44
+      :guid => "a-trigger-agent",
45
+      :options => trigger_agent_options
46
+    }
47
+  end
48
+  let(:valid_parsed_data) do
49
+    { 
50
+      :name => name,
51
+      :description => description,
52
+      :guid => guid,
53
+      :source_url => source_url,
54
+      :exported_at => 2.days.ago.utc.iso8601,
55
+      :agents => [
56
+        valid_parsed_weather_agent_data,
57
+        valid_parsed_trigger_agent_data
58
+      ],
59
+      :links => [
60
+        { :source => 0, :receiver => 1 }
61
+      ]
62
+    }
63
+  end
64
+  let(:valid_data) { valid_parsed_data.to_json }
65
+  let(:invalid_data) { { :name => "some scenario missing a guid" }.to_json }
66
+
67
+  describe "initialization" do
68
+    it "is initialized with an attributes hash" do
69
+      ScenarioImport.new(:url => "http://google.com").url.should == "http://google.com"
70
+    end
71
+  end
72
+
73
+  describe "validations" do
74
+    subject do
75
+      _import = ScenarioImport.new
76
+      _import.set_user(user)
77
+      _import
78
+    end
79
+
80
+    it "is not valid when none of file, url, or data are present" do
81
+      subject.should_not be_valid
82
+      subject.should have(1).error_on(:base)
83
+      subject.errors[:base].should include("Please provide either a Scenario JSON File or a Public Scenario URL.")
84
+    end
85
+
86
+    describe "data" do
87
+      it "should be invalid with invalid data" do
88
+        subject.data = invalid_data
89
+        subject.should_not be_valid
90
+        subject.should have(1).error_on(:base)
91
+
92
+        subject.data = "foo"
93
+        subject.should_not be_valid
94
+        subject.should have(1).error_on(:base)
95
+
96
+        # It also clears the data when invalid
97
+        subject.data.should be_nil
98
+      end
99
+
100
+      it "should be valid with valid data" do
101
+        subject.data = valid_data
102
+        subject.should be_valid
103
+      end
104
+    end
105
+
106
+    describe "url" do
107
+      it "should be invalid with an unreasonable URL" do
108
+        subject.url = "foo"
109
+        subject.should_not be_valid
110
+        subject.should have(1).error_on(:url)
111
+        subject.errors[:url].should include("appears to be invalid")
112
+      end
113
+
114
+      it "should be invalid when the referenced url doesn't contain a scenario" do
115
+        stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => invalid_data)
116
+        subject.url = "http://example.com/scenarios/1/export.json"
117
+        subject.should_not be_valid
118
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
119
+      end
120
+
121
+      it "should be valid when the url points to a valid scenario" do
122
+        stub_request(:get, "http://example.com/scenarios/1/export.json").to_return(:status => 200, :body => valid_data)
123
+        subject.url = "http://example.com/scenarios/1/export.json"
124
+        subject.should be_valid
125
+      end
126
+    end
127
+
128
+    describe "file" do
129
+      it "should be invalid when the uploaded file doesn't contain a scenario" do
130
+        subject.file = StringIO.new("foo")
131
+        subject.should_not be_valid
132
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
133
+
134
+        subject.file = StringIO.new(invalid_data)
135
+        subject.should_not be_valid
136
+        subject.errors[:base].should include("The provided data does not appear to be a valid Scenario.")
137
+      end
138
+
139
+      it "should be valid with a valid uploaded scenario" do
140
+        subject.file = StringIO.new(valid_data)
141
+        subject.should be_valid
142
+      end
143
+    end
144
+  end
145
+  
146
+  describe "#dangerous?" do
147
+    it "returns false on most Agents" do
148
+      ScenarioImport.new(:data => valid_data).should_not be_dangerous
149
+    end
150
+
151
+    it "returns true if a ShellCommandAgent is present" do
152
+      valid_parsed_data[:agents][0][:type] = "Agents::ShellCommandAgent"
153
+      ScenarioImport.new(:data => valid_parsed_data.to_json).should be_dangerous
154
+    end
155
+  end
156
+
157
+  describe "#import and #generate_diff" do
158
+    let(:scenario_import) do
159
+      _import = ScenarioImport.new(:data => valid_data)
160
+      _import.set_user users(:bob)
161
+      _import
162
+    end
163
+
164
+    context "when this scenario has never been seen before" do
165
+      describe "#import" do
166
+        it "makes a new scenario" do
167
+          lambda {
168
+            scenario_import.import(:skip_agents => true)
169
+          }.should change { users(:bob).scenarios.count }.by(1)
170
+
171
+          scenario_import.scenario.name.should == name
172
+          scenario_import.scenario.description.should == description
173
+          scenario_import.scenario.guid.should == guid
174
+          scenario_import.scenario.source_url.should == source_url
175
+          scenario_import.scenario.public.should be_false
176
+        end
177
+
178
+        it "creates the Agents" do
179
+          lambda {
180
+            scenario_import.import
181
+          }.should change { users(:bob).agents.count }.by(2)
182
+
183
+          weather_agent = scenario_import.scenario.agents.find_by(:guid => "a-weather-agent")
184
+          trigger_agent = scenario_import.scenario.agents.find_by(:guid => "a-trigger-agent")
185
+
186
+          weather_agent.name.should == "a weather agent"
187
+          weather_agent.schedule.should == "5pm"
188
+          weather_agent.keep_events_for.should == 14
189
+          weather_agent.propagate_immediately.should be_false
190
+          weather_agent.should be_disabled
191
+          weather_agent.memory.should be_empty
192
+          weather_agent.options.should == weather_agent_options
193
+
194
+          trigger_agent.name.should == "listen for weather"
195
+          trigger_agent.sources.should == [weather_agent]
196
+          trigger_agent.schedule.should be_nil
197
+          trigger_agent.keep_events_for.should == 0
198
+          trigger_agent.propagate_immediately.should be_true
199
+          trigger_agent.should_not be_disabled
200
+          trigger_agent.memory.should be_empty
201
+          trigger_agent.options.should == trigger_agent_options
202
+        end
203
+
204
+        it "creates new Agents, even if one already exists with the given guid (so that we don't overwrite a user's work outside of the scenario)" do
205
+          agents(:bob_weather_agent).update_attribute :guid, "a-weather-agent"
206
+
207
+          lambda {
208
+            scenario_import.import
209
+          }.should change { users(:bob).agents.count }.by(2)
210
+        end
211
+      end
212
+
213
+      describe "#generate_diff" do
214
+        it "returns AgentDiff objects for the incoming Agents" do
215
+          scenario_import.should be_valid
216
+
217
+          agent_diffs = scenario_import.agent_diffs
218
+
219
+          weather_agent_diff = agent_diffs[0]
220
+          trigger_agent_diff = agent_diffs[1]
221
+
222
+          valid_parsed_weather_agent_data.each do |key, value|
223
+            if key == :type
224
+              value = value.split("::").last
225
+            end
226
+            weather_agent_diff.should respond_to(key)
227
+            field = weather_agent_diff.send(key)
228
+            field.should be_a(ScenarioImport::AgentDiff::FieldDiff)
229
+            field.incoming.should == value
230
+            field.updated.should == value
231
+            field.current.should be_nil
232
+          end
233
+          weather_agent_diff.should_not respond_to(:propagate_immediately)
234
+
235
+          valid_parsed_trigger_agent_data.each do |key, value|
236
+            if key == :type
237
+              value = value.split("::").last
238
+            end
239
+            trigger_agent_diff.should respond_to(key)
240
+            field = trigger_agent_diff.send(key)
241
+            field.should be_a(ScenarioImport::AgentDiff::FieldDiff)
242
+            field.incoming.should == value
243
+            field.updated.should == value
244
+            field.current.should be_nil
245
+          end
246
+          trigger_agent_diff.should_not respond_to(:schedule)
247
+        end
248
+      end
249
+    end
250
+
251
+    context "when an a scenario already exists with the given guid" do
252
+      let!(:existing_scenario) do
253
+        _existing_scenerio = users(:bob).scenarios.build(:name => "an existing scenario", :description => "something")
254
+        _existing_scenerio.guid = guid
255
+        _existing_scenerio.save!
256
+
257
+        agents(:bob_weather_agent).update_attribute :guid, "a-weather-agent"
258
+        agents(:bob_weather_agent).scenarios << _existing_scenerio
259
+
260
+        _existing_scenerio
261
+      end
262
+
263
+      describe "#import" do
264
+        it "uses the existing scenario, updating its data" do
265
+          lambda {
266
+            scenario_import.import(:skip_agents => true)
267
+            scenario_import.scenario.should == existing_scenario
268
+          }.should_not change { users(:bob).scenarios.count }
269
+
270
+          existing_scenario.reload
271
+          existing_scenario.guid.should == guid
272
+          existing_scenario.description.should == description
273
+          existing_scenario.name.should == name
274
+          existing_scenario.source_url.should == source_url
275
+          existing_scenario.public.should be_false
276
+        end
277
+
278
+        it "updates any existing agents in the scenario, and makes new ones as needed" do
279
+          scenario_import.should be_valid
280
+
281
+          lambda {
282
+            scenario_import.import
283
+          }.should change { users(:bob).agents.count }.by(1) # One, because the weather agent already existed.
284
+
285
+          weather_agent = existing_scenario.agents.find_by(:guid => "a-weather-agent")
286
+          trigger_agent = existing_scenario.agents.find_by(:guid => "a-trigger-agent")
287
+
288
+          weather_agent.should == agents(:bob_weather_agent)
289
+
290
+          weather_agent.name.should == "a weather agent"
291
+          weather_agent.schedule.should == "5pm"
292
+          weather_agent.keep_events_for.should == 14
293
+          weather_agent.propagate_immediately.should be_false
294
+          weather_agent.should be_disabled
295
+          weather_agent.memory.should be_empty
296
+          weather_agent.options.should == weather_agent_options
297
+
298
+          trigger_agent.name.should == "listen for weather"
299
+          trigger_agent.sources.should == [weather_agent]
300
+          trigger_agent.schedule.should be_nil
301
+          trigger_agent.keep_events_for.should == 0
302
+          trigger_agent.propagate_immediately.should be_true
303
+          trigger_agent.should_not be_disabled
304
+          trigger_agent.memory.should be_empty
305
+          trigger_agent.options.should == trigger_agent_options
306
+        end
307
+
308
+        it "honors updates coming from the UI" do
309
+          scenario_import.merges = {
310
+            "0" => {
311
+              "name" => "updated name",
312
+              "schedule" => "6pm",
313
+              "keep_events_for" => "2",
314
+              "disabled" => "false",
315
+              "options" => weather_agent_options.merge("api_key" => "foo").to_json
316
+            }
317
+          }
318
+
319
+          scenario_import.should be_valid
320
+
321
+          scenario_import.import.should be_true
322
+
323
+          weather_agent = existing_scenario.agents.find_by(:guid => "a-weather-agent")
324
+          weather_agent.name.should == "updated name"
325
+          weather_agent.schedule.should == "6pm"
326
+          weather_agent.keep_events_for.should == 2
327
+          weather_agent.should_not be_disabled
328
+          weather_agent.options.should == weather_agent_options.merge("api_key" => "foo")
329
+        end
330
+
331
+        it "adds errors when updated agents are invalid" do
332
+          scenario_import.merges = {
333
+            "0" => {
334
+              "name" => "",
335
+              "schedule" => "foo",
336
+              "keep_events_for" => "2",
337
+              "options" => weather_agent_options.merge("api_key" => "").to_json
338
+            }
339
+          }
340
+
341
+          scenario_import.import.should be_false
342
+
343
+          errors = scenario_import.errors.full_messages.to_sentence
344
+          errors.should =~ /Name can't be blank/
345
+          errors.should =~ /api_key is required/
346
+          errors.should =~ /Schedule is not a valid schedule/
347
+        end
348
+      end
349
+
350
+      describe "#generate_diff" do
351
+        it "returns AgentDiff objects that include 'current' values from any agents that already exist" do
352
+          agent_diffs = scenario_import.agent_diffs
353
+          weather_agent_diff = agent_diffs[0]
354
+          trigger_agent_diff = agent_diffs[1]
355
+
356
+          # Already exists
357
+          weather_agent_diff.agent.should == agents(:bob_weather_agent)
358
+          valid_parsed_weather_agent_data.each do |key, value|
359
+            next if key == :type
360
+            weather_agent_diff.send(key).current.should == agents(:bob_weather_agent).send(key)
361
+          end
362
+
363
+          # Doesn't exist yet
364
+          valid_parsed_trigger_agent_data.each do |key, value|
365
+            trigger_agent_diff.send(key).current.should be_nil
366
+          end
367
+        end
368
+
369
+        it "sets the 'updated' FieldDiff values based on any feedback from the user" do
370
+          scenario_import.merges = {
371
+            "0" => {
372
+              "name" => "a new name",
373
+              "schedule" => "6pm",
374
+              "keep_events_for" => "2",
375
+              "disabled" => "true",
376
+              "options" => weather_agent_options.merge("api_key" => "foo").to_json
377
+            },
378
+            "1" => {
379
+              "name" => "another new name"
380
+            }
381
+          }
382
+
383
+          scenario_import.should be_valid
384
+
385
+          agent_diffs = scenario_import.agent_diffs
386
+          weather_agent_diff = agent_diffs[0]
387
+          trigger_agent_diff = agent_diffs[1]
388
+
389
+          weather_agent_diff.name.current.should == agents(:bob_weather_agent).name
390
+          weather_agent_diff.name.incoming.should == valid_parsed_weather_agent_data[:name]
391
+          weather_agent_diff.name.updated.should == "a new name"
392
+
393
+          weather_agent_diff.schedule.updated.should == "6pm"
394
+          weather_agent_diff.keep_events_for.updated.should == "2"
395
+          weather_agent_diff.disabled.updated.should == "true"
396
+          weather_agent_diff.options.updated.should == weather_agent_options.merge("api_key" => "foo")
397
+        end
398
+
399
+        it "adds errors on validation when updated options are unparsable" do
400
+          scenario_import.merges = {
401
+            "0" => {
402
+              "options" => '{'
403
+            }
404
+          }
405
+          scenario_import.should_not be_valid
406
+          scenario_import.should have(1).error_on(:base)
407
+        end
408
+      end
409
+    end
410
+  end
411
+end

+ 43 - 0
spec/models/scenario_spec.rb

@@ -0,0 +1,43 @@
1
+require 'spec_helper'
2
+
3
+describe Scenario do
4
+  let(:new_instance) { users(:bob).scenarios.build(:name => "some scenario") }
5
+
6
+  it_behaves_like HasGuid
7
+
8
+  describe "validations" do
9
+    before do
10
+      new_instance.should be_valid
11
+    end
12
+
13
+    it "validates the presence of name" do
14
+      new_instance.name = ''
15
+      new_instance.should_not be_valid
16
+    end
17
+
18
+    it "validates the presence of user" do
19
+      new_instance.user = nil
20
+      new_instance.should_not be_valid
21
+    end
22
+
23
+    it "only allows Agents owned by user" do
24
+      new_instance.agent_ids = [agents(:bob_website_agent).id]
25
+      new_instance.should be_valid
26
+
27
+      new_instance.agent_ids = [agents(:jane_website_agent).id]
28
+      new_instance.should_not be_valid
29
+    end
30
+  end
31
+
32
+  describe "counters" do
33
+    it "maintains a counter cache on user" do
34
+      lambda {
35
+        new_instance.save!
36
+      }.should change { users(:bob).reload.scenario_count }.by(1)
37
+
38
+      lambda {
39
+        new_instance.destroy
40
+      }.should change { users(:bob).reload.scenario_count }.by(-1)
41
+    end
42
+  end
43
+end

+ 12 - 0
spec/support/shared_examples/has_guid.rb

@@ -0,0 +1,12 @@
1
+require 'spec_helper'
2
+
3
+shared_examples_for HasGuid do
4
+  it "gets created before_save, but only if it's not present" do
5
+    instance = new_instance
6
+    instance.guid.should be_nil
7
+    instance.save!
8
+    instance.guid.should_not be_nil
9
+
10
+    lambda { instance.save! }.should_not change { instance.reload.guid }
11
+  end
12
+end

+ 2 - 1
spec/models/concerns/liquid_interpolatable.rb

@@ -28,7 +28,7 @@ shared_examples_for LiquidInterpolatable do
28 28
       }
29 29
     end
30 30
 
31
-    it "hsould work with arrays", focus: true do
31
+    it "should work with arrays", focus: true do
32 32
       @checker.options = {"value" => ["{{variable}}", "Much array", "Hey, {{hello_world}}"]}
33 33
       @checker.interpolate_options(@checker.options, @event.payload).should == {
34 34
         "value" => ["hello", "Much array", "Hey, Hello world"]
@@ -53,6 +53,7 @@ shared_examples_for LiquidInterpolatable do
53 53
       @checker.interpolate_string("{{variable}} you", @event.payload).should == "hello you"
54 54
     end
55 55
   end
56
+
56 57
   describe "liquid tags" do
57 58
     it "should work with existing credentials" do
58 59
       @checker.interpolate_string("{% credential aws_key %}", {}).should == '2222222222-jane'

+ 3 - 3
spec/models/concerns/working_helpers.rb

@@ -3,7 +3,7 @@ require 'spec_helper'
3 3
 shared_examples_for WorkingHelpers do
4 4
   describe "recent_error_logs?" do
5 5
     it "returns true if last_error_log_at is near last_event_at" do
6
-      agent = Agent.new
6
+      agent = described_class.new
7 7
 
8 8
       agent.last_error_log_at = 10.minutes.ago
9 9
       agent.last_event_at = 10.minutes.ago
@@ -26,9 +26,10 @@ shared_examples_for WorkingHelpers do
26 26
       agent.recent_error_logs?.should be_false
27 27
     end
28 28
   end
29
+
29 30
   describe "received_event_without_error?" do
30 31
     before do
31
-      @agent = Agent.new
32
+      @agent = described_class.new
32 33
     end
33 34
 
34 35
     it "should return false until the first event was received" do
@@ -49,5 +50,4 @@ shared_examples_for WorkingHelpers do
49 50
       @agent.received_event_without_error?.should == true
50 51
     end
51 52
   end
52
-
53 53
 end

+ 514 - 489
vendor/assets/javascripts/jquery.json-editor.js

@@ -1,5 +1,5 @@
1 1
 /*
2
-  Copyright (c) 2013, Andrew Cantino
2
+  Copyright (c) 2014, Andrew Cantino
3 3
   Copyright (c) 2009, Andrew Cantino & Kyle Maxwell
4 4
 
5 5
   Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -23,8 +23,8 @@
23 23
 
24 24
 
25 25
 
26
-  You will probably need to tell the editor where to find its add and delete images.  In your
27
-  code before you make the editor, do something like this:
26
+  You will probably need to tell the editor where to find its 'add' and 'delete' images.  In your
27
+  code, before you make the editor, do something like this:
28 28
      JSONEditor.prototype.ADD_IMG = '/javascripts/jsoneditor/add.png';
29 29
      JSONEditor.prototype.DELETE_IMG = '/javascripts/jsoneditor/delete.png';
30 30
 
@@ -36,504 +36,529 @@
36 36
 */
37 37
 
38 38
 
39
-function JSONEditorBase(options) {
40
-  if (!options) options = {};
41
-  this.builderShowing = true;
42
-  this.ADD_IMG = options.ADD_IMG || 'lib/images/add.png';
43
-  this.DELETE_IMG = options.DELETE_IMG || 'lib/images/delete.png';
44
-  this.functionButtonsEnabled = false;
45
-  this._doTruncation = true;
46
-  this._showWipe = options.showWipe;
47
-}
48
-
49
-function JSONEditor(wrapped, width, height) {
50
-  this.history = [];
51
-  this.historyPointer = -1;
52
-  if (wrapped == null || (wrapped.get && wrapped.get(0) == null)) throw "Must provide an element to wrap.";
53
-  var width = width || 600;
54
-  var height = height || 300;
55
-  this.wrapped = $(wrapped);
56
-
57
-  this.wrapped.wrap('<div class="json-editor"></div>');
58
-  this.container = $(this.wrapped.parent());
59
-  this.container.width(width).height(height);
60
-  this.wrapped.width(width).height(height);
61
-  this.wrapped.hide();
62
-  this.container.css("position", "relative");
63
-  this.doAutoFocus = false;
64
-  this.editingUnfocused();
65
-
66
-  this.rebuild();
67
-  var self = this;
68
-  this.container.focus(function(){
69
-  	$(this).children('textarea').height(self.container.height() - self.functionButtons.height() - 5);
70
-  	$(this).children('.builder').height(self.container.height() - self.functionButtons.height() - 10);
71
-  });
72
-
73
-  return this;
74
-}
75
-JSONEditor.prototype = new JSONEditorBase();
76
-
77
-JSONEditor.prototype.braceUI = function(key, struct) {
78
-  var self = this;
79
-  return $('<a class="icon" href="#"><strong>{</strong></a>').click(function(e) {
80
-    struct[key] = { "??": struct[key] };
81
-    self.doAutoFocus = true;
82
-    self.rebuild();
83
-    return false;
84
-  });
85
-};
86
-
87
-JSONEditor.prototype.bracketUI = function(key, struct) {
88
-  var self = this;
89
-  return $('<a class="icon" href="#"><strong>[</a>').click(function(e) {
90
-    struct[key] = [ struct[key] ];
91
-    self.doAutoFocus = true;
92
-    self.rebuild();
93
-    return false;
94
-  });
95
-};
96
-
97
-JSONEditor.prototype.deleteUI = function(key, struct, fullDelete) {
98
-  var self = this;
99
-  return $('<a class="icon" href="#" title="delete"><img src="' + this.DELETE_IMG + '" border=0/></a>').click(function(e) {
100
-    if (!fullDelete) {
101
-      var didSomething = false;
102
-      if (struct[key] instanceof Array) {
103
-        if(struct[key].length > 0) {
104
-          struct[key] = struct[key][0];
105
-          didSomething = true;
39
+(function() {
40
+
41
+  window.JSONEditor = (function() {
42
+
43
+    function JSONEditor(wrapped, options) {
44
+      if (options == null) {
45
+        options = {};
46
+      }
47
+      this.builderShowing = true;
48
+      this.ADD_IMG || (this.ADD_IMG = options.ADD_IMG || 'lib/images/add.png');
49
+      this.DELETE_IMG || (this.DELETE_IMG = options.DELETE_IMG || 'lib/images/delete.png');
50
+      this.functionButtonsEnabled = false;
51
+      this._doTruncation = true;
52
+      this._showWipe = options.showWipe;
53
+      this.history = [];
54
+      this.historyPointer = -1;
55
+      if (wrapped === null || (wrapped.get && wrapped.get(0) === null)) {
56
+        throw "Must provide an element to wrap.";
57
+      }
58
+      this.wrapped = $(wrapped);
59
+      this.wrapped.wrap('<div class="json-editor"></div>');
60
+      this.container = $(this.wrapped.parent());
61
+      this.wrapped.hide();
62
+      this.container.css("position", "relative");
63
+      this.doAutoFocus = false;
64
+      this.editingUnfocused();
65
+      this.rebuild();
66
+    }
67
+
68
+    JSONEditor.prototype.braceUI = function(key, struct) {
69
+      var _this = this;
70
+      return $('<a class="icon" href="#"><strong>{</strong></a>').click(function(e) {
71
+        e.preventDefault();
72
+        struct[key] = {
73
+          "??": struct[key]
74
+        };
75
+        _this.doAutoFocus = true;
76
+        return _this.rebuild();
77
+      });
78
+    };
79
+
80
+    JSONEditor.prototype.bracketUI = function(key, struct) {
81
+      var _this = this;
82
+      return $('<a class="icon" href="#"><strong>[</a>').click(function(e) {
83
+        e.preventDefault();
84
+        struct[key] = [struct[key]];
85
+        _this.doAutoFocus = true;
86
+        return _this.rebuild();
87
+      });
88
+    };
89
+
90
+    JSONEditor.prototype.deleteUI = function(key, struct, fullDelete) {
91
+      var _this = this;
92
+      return $("<a class='icon' href='#' title='delete'><img src='" + this.DELETE_IMG + "' border=0 /></a>").click(function(e) {
93
+        var didSomething, subkey, subval, _ref;
94
+        e.preventDefault();
95
+        if (!fullDelete) {
96
+          didSomething = false;
97
+          if (struct[key] instanceof Array) {
98
+            if (struct[key].length > 0) {
99
+              struct[key] = struct[key][0];
100
+              didSomething = true;
101
+            }
102
+          } else if (struct[key] instanceof Object) {
103
+            _ref = struct[key];
104
+            for (subkey in _ref) {
105
+              subval = _ref[subkey];
106
+              struct[key] = struct[key][subkey];
107
+              didSomething = true;
108
+              break;
109
+            }
110
+          }
111
+          if (didSomething) {
112
+            _this.rebuild();
113
+            return;
114
+          }
106 115
         }
107
-      } else if (struct[key] instanceof Object) {
108
-        for (var i in struct[key]) {
109
-          struct[key] = struct[key][i];
110
-          didSomething = true;
111
-          break;
116
+        if (struct instanceof Array) {
117
+          struct.splice(key, 1);
118
+        } else {
119
+          delete struct[key];
120
+        }
121
+        return _this.rebuild();
122
+      });
123
+    };
124
+
125
+    JSONEditor.prototype.wipeUI = function(key, struct) {
126
+      var _this = this;
127
+      return $('<a class="icon" href="#" title="wipe"><strong>W</strong></a>').click(function(e) {
128
+        e.preventDefault();
129
+        if (struct instanceof Array) {
130
+          struct.splice(key, 1);
131
+        } else {
132
+          delete struct[key];
133
+        }
134
+        return _this.rebuild();
135
+      });
136
+    };
137
+
138
+    JSONEditor.prototype.addUI = function(struct) {
139
+      var _this = this;
140
+      return $("<a class='icon' href='#' title='add'><img src='" + this.ADD_IMG + "' border=0/></a>").click(function(e) {
141
+        e.preventDefault();
142
+        if (struct instanceof Array) {
143
+          struct.push('??');
144
+        } else {
145
+          struct['??'] = '??';
146
+        }
147
+        _this.doAutoFocus = true;
148
+        return _this.rebuild();
149
+      });
150
+    };
151
+
152
+    JSONEditor.prototype.undo = function() {
153
+      if (this.saveStateIfTextChanged()) {
154
+        if (this.historyPointer > 0) {
155
+          this.historyPointer -= 1;
156
+        }
157
+        return this.restore();
158
+      }
159
+    };
160
+
161
+    JSONEditor.prototype.redo = function() {
162
+      if (this.historyPointer + 1 < this.history.length) {
163
+        if (this.saveStateIfTextChanged()) {
164
+          this.historyPointer += 1;
165
+          return this.restore();
112 166
         }
113 167
       }
114
-      if (didSomething) {
115
-        self.rebuild();
168
+    };
169
+
170
+    JSONEditor.prototype.showBuilder = function() {
171
+      if (this.checkJsonInText()) {
172
+        this.setJsonFromText();
173
+        this.rebuild();
174
+        this.wrapped.hide();
175
+        this.builder.show();
176
+        return true;
177
+      } else {
178
+        alert("Sorry, there appears to be an error in your JSON input.  Please fix it before continuing.");
116 179
         return false;
117 180
       }
118
-    }
119
-    if (struct instanceof Array) {
120
-      struct.splice(key, 1);
121
-    } else {
122
-      delete struct[key];
123
-    }
124
-    self.rebuild();
125
-    return false;
126
-  });
127
-};
128
-
129
-JSONEditor.prototype.wipeUI = function(key, struct) {
130
-  var self = this;
131
-  return $('<a class="icon" href="#" title="wipe"><strong>W</strong></a>').click(function(e) {
132
-    if (struct instanceof Array) {
133
-      struct.splice(key, 1);
134
-    } else {
135
-      delete struct[key];
136
-    }
137
-    self.rebuild();
138
-    return false;
139
-  });
140
-};
141
-
142
-JSONEditor.prototype.addUI = function(struct) {
143
-  var self = this;
144
-  return $('<a class="icon" href="#" title="add"><img src="' + this.ADD_IMG + '" border=0/></a>').click(function(e) {
145
-    if (struct instanceof Array) {
146
-      struct.push('??');
147
-    } else {
148
-      struct['??'] = '??';
149
-    }
150
-    self.doAutoFocus = true;
151
-    self.rebuild();
152
-    return false;
153
-  });
154
-};
155
-
156
-JSONEditor.prototype.undo = function() {
157
-  if (this.saveStateIfTextChanged()) {
158
-    if (this.historyPointer > 0) this.historyPointer -= 1;
159
-    this.restore();
160
-  }
161
-};
162
-
163
-JSONEditor.prototype.redo = function() {
164
-  if (this.historyPointer + 1 < this.history.length) {
165
-    if (this.saveStateIfTextChanged()) {
166
-      this.historyPointer += 1;
167
-      this.restore();
168
-    }
169
-  }
170
-};
171
-
172
-JSONEditor.prototype.showBuilder = function() {
173
-  if (this.checkJsonInText()) {
174
-    this.setJsonFromText();
175
-    this.rebuild();
176
-    this.wrapped.hide();
177
-    this.builder.show();
178
-    return true;
179
-  } else {
180
-    alert("Sorry, there appears to be an error in your JSON input.  Please fix it before continuing.");
181
-    return false;
182
-  }
183
-};
184
-
185
-JSONEditor.prototype.showText = function() {
186
-  this.builder.hide();
187
-  this.wrapped.show();
188
-};
189
-
190
-JSONEditor.prototype.toggleBuilder = function() {
191
-    if(this.builderShowing){
192
-      this.showText();
193
-      this.builderShowing = !this.builderShowing;
194
-    } else {
195
-      if (this.showBuilder()) {
196
-        this.builderShowing = !this.builderShowing;
181
+    };
182
+
183
+    JSONEditor.prototype.showText = function() {
184
+      this.builder.hide();
185
+      return this.wrapped.show();
186
+    };
187
+
188
+    JSONEditor.prototype.toggleBuilder = function() {
189
+      if (this.builderShowing) {
190
+        this.showText();
191
+        return this.builderShowing = !this.builderShowing;
192
+      } else {
193
+        if (this.showBuilder()) {
194
+          return this.builderShowing = !this.builderShowing;
195
+        }
197 196
       }
198
-    }
199
-};
200
-
201
-JSONEditor.prototype.showFunctionButtons = function(insider) {
202
-  if (!insider) this.functionButtonsEnabled = true;
203
-  if (this.functionButtonsEnabled) if (!this.functionButtons) {
204
-    this.functionButtons = $('<div class="function_buttons"></div>');
205
-    var self = this;
206
-    this.functionButtons.append($('<a href="#" style="padding-right: 10px;"></a>').click(function() {
207
-      self.undo();
208
-      return false;
209
-    }).text('Undo')).append($('<a href="#" style="padding-right: 10px;"></a>').click(function() {
210
-      self.redo();
211
-      return false;
212
-    }).text('Redo')).append($('<a id="toggle_view" href="#" style="padding-right: 10px;"></a>').click(function() {
213
-      self.toggleBuilder();
214
-      return false;
215
-    }).text('Toggle View'));
216
-    this.container.prepend(this.functionButtons);
217
-    this.container.height(this.container.height() + this.functionButtons.height() + 5);
218
-  }
219
-  if (this.functionButtons) {
220
-    this.wrapped.css('top', this.functionButtons.height() + 5 + 'px');
221
-    this.builder.css('top', this.functionButtons.height() + 5 + 'px');
222
-  }
223
-};
224
-
225
-JSONEditor.prototype.saveStateIfTextChanged = function() {
226
-  if (JSON.stringify(this.json, null, 2) != this.wrapped.get(0).value) {
227
-    if (this.checkJsonInText()) {
228
-      this.saveState(true);
229
-    } else {
230
-      if (confirm("The current JSON is malformed.  If you continue, the current JSON will not be saved.  Do you wish to continue?")) {
231
-        this.historyPointer += 1;
197
+    };
198
+
199
+    JSONEditor.prototype.showFunctionButtons = function(insider) {
200
+      var _this = this;
201
+      if (!insider) {
202
+        this.functionButtonsEnabled = true;
203
+      }
204
+      if (this.functionButtonsEnabled && !this.functionButtons) {
205
+        this.functionButtons = $('<div class="function_buttons"></div>');
206
+        this.functionButtons.append($('<a href="#" style="padding-right: 10px;">Undo</a>').click(function(e) {
207
+          e.preventDefault();
208
+          return _this.undo();
209
+        }));
210
+        this.functionButtons.append($('<a href="#" style="padding-right: 10px;">Redo</a>').click(function(e) {
211
+          e.preventDefault();
212
+          return _this.redo();
213
+        }));
214
+        this.functionButtons.append($('<a id="toggle_view" href="#" style="padding-right: 10px; float: right;">Toggle View</a>').click(function(e) {
215
+          e.preventDefault();
216
+          return _this.toggleBuilder();
217
+        }));
218
+        return this.container.prepend(this.functionButtons);
219
+      }
220
+    };
221
+
222
+    JSONEditor.prototype.saveStateIfTextChanged = function() {
223
+      if (JSON.stringify(this.json, null, 2) !== this.wrapped.get(0).value) {
224
+        if (this.checkJsonInText()) {
225
+          this.saveState(true);
226
+        } else {
227
+          if (confirm("The current JSON is malformed.  If you continue, the current JSON will not be saved.  Do you wish to continue?")) {
228
+            this.historyPointer += 1;
229
+            true;
230
+          } else {
231
+            false;
232
+          }
233
+        }
234
+      }
235
+      return true;
236
+    };
237
+
238
+    JSONEditor.prototype.restore = function() {
239
+      if (this.history[this.historyPointer]) {
240
+        this.wrapped.get(0).value = this.history[this.historyPointer];
241
+        return this.rebuild(true);
242
+      }
243
+    };
244
+
245
+    JSONEditor.prototype.saveState = function(skipStoreText) {
246
+      var text;
247
+      if (this.json) {
248
+        if (!skipStoreText) {
249
+          this.storeToText();
250
+        }
251
+        text = this.wrapped.get(0).value;
252
+        if (this.history[this.historyPointer] !== text) {
253
+          this.historyTruncate();
254
+          this.history.push(text);
255
+          return this.historyPointer += 1;
256
+        }
257
+      }
258
+    };
259
+
260
+    JSONEditor.prototype.fireChange = function() {
261
+      return $(this.wrapped).trigger('change');
262
+    };
263
+
264
+    JSONEditor.prototype.historyTruncate = function() {
265
+      if (this.historyPointer + 1 < this.history.length) {
266
+        return this.history.splice(this.historyPointer + 1, this.history.length - this.historyPointer);
267
+      }
268
+    };
269
+
270
+    JSONEditor.prototype.storeToText = function() {
271
+      return this.wrapped.get(0).value = JSON.stringify(this.json, null, 2);
272
+    };
273
+
274
+    JSONEditor.prototype.getJSONText = function() {
275
+      this.rebuild();
276
+      return this.wrapped.get(0).value;
277
+    };
278
+
279
+    JSONEditor.prototype.getJSON = function() {
280
+      this.rebuild();
281
+      return this.json;
282
+    };
283
+
284
+    JSONEditor.prototype.rebuild = function(doNotRefreshText) {
285
+      var changed, elem;
286
+      if (!this.json) {
287
+        this.setJsonFromText();
288
+      }
289
+      changed = this.haveThingsChanged();
290
+      if (this.json && !doNotRefreshText) {
291
+        this.saveState();
292
+      }
293
+      this.cleanBuilder();
294
+      this.setJsonFromText();
295
+      this.alreadyFocused = false;
296
+      elem = this.build(this.json, this.builder, null, null, this.json);
297
+      this.recoverScrollPosition();
298
+      if (elem && elem.text() === '??' && !this.alreadyFocused && this.doAutoFocus) {
299
+        this.alreadyFocused = true;
300
+        this.doAutoFocus = false;
301
+        elem = elem.find('.editable');
302
+        elem.click();
303
+        elem.find('input').focus().select();
304
+      }
305
+      if (changed) {
306
+        return this.fireChange();
307
+      }
308
+    };
309
+
310
+    JSONEditor.prototype.haveThingsChanged = function() {
311
+      return this.json && JSON.stringify(this.json, null, 2) !== this.wrapped.get(0).value;
312
+    };
313
+
314
+    JSONEditor.prototype.saveScrollPosition = function() {
315
+      return this.oldScrollHeight = this.builder.scrollTop();
316
+    };
317
+
318
+    JSONEditor.prototype.recoverScrollPosition = function() {
319
+      return this.builder.scrollTop(this.oldScrollHeight);
320
+    };
321
+
322
+    JSONEditor.prototype.setJsonFromText = function() {
323
+      if (this.wrapped.get(0).value.length === 0) {
324
+        this.wrapped.get(0).value = "{}";
325
+      }
326
+      try {
327
+        this.wrapped.get(0).value = this.wrapped.get(0).value.replace(/((^|[^\\])(\\\\)*)\\n/g, '$1\\\\n').replace(/((^|[^\\])(\\\\)*)\\t/g, '$1\\\\t');
328
+        return this.json = JSON.parse(this.wrapped.get(0).value);
329
+      } catch (e) {
330
+        return alert("Got bad JSON from text.");
331
+      }
332
+    };
333
+
334
+    JSONEditor.prototype.checkJsonInText = function() {
335
+      try {
336
+        JSON.parse(this.wrapped.get(0).value);
232 337
         return true;
233
-      } else {
338
+      } catch (e) {
234 339
         return false;
235 340
       }
236
-    }
237
-  }
238
-  return true;
239
-};
240
-
241
-JSONEditor.prototype.restore = function() {
242
-  if (this.history[this.historyPointer]) {
243
-    this.wrapped.get(0).value = this.history[this.historyPointer];
244
-    this.rebuild(true);
245
-  }
246
-};
247
-
248
-JSONEditor.prototype.saveState = function(skipStoreText) {
249
-  if (this.json) {
250
-    if (!skipStoreText) this.storeToText();
251
-    var text = this.wrapped.get(0).value;
252
-    if (this.history[this.historyPointer] != text) {
253
-      this.historyTruncate();
254
-      this.history.push(text);
255
-      this.historyPointer += 1;
256
-    }
257
-  }
258
-};
259
-
260
-JSONEditor.prototype.fireChange = function() {
261
-  $(this.wrapped).trigger('change');
262
-};
263
-
264
-JSONEditor.prototype.historyTruncate = function() {
265
-  if (this.historyPointer + 1 < this.history.length) {
266
-    this.history.splice(this.historyPointer + 1, this.history.length - this.historyPointer);
267
-  }
268
-};
269
-
270
-JSONEditor.prototype.storeToText = function() {
271
-  this.wrapped.get(0).value = JSON.stringify(this.json, null, 2);
272
-};
273
-
274
-JSONEditor.prototype.getJSONText = function() {
275
-  this.rebuild();
276
-  return this.wrapped.get(0).value;
277
-};
278
-
279
-JSONEditor.prototype.getJSON = function() {
280
-  this.rebuild();
281
-  return this.json;
282
-};
283
-
284
-JSONEditor.prototype.rebuild = function(doNotRefreshText) {
285
-  if (!this.json) this.setJsonFromText();
286
-  var changed = this.haveThingsChanged();
287
-  if (this.json && !doNotRefreshText) {
288
-    this.saveState();
289
-  }
290
-  this.cleanBuilder();
291
-  this.setJsonFromText();
292
-  this.alreadyFocused = false;
293
-  var elem = this.build(this.json, this.builder, null, null, this.json);
294
-
295
-  this.recoverScrollPosition();
296
-
297
-  // Auto-focus to edit '??' keys and values.
298
-  if (elem) if (elem.text() == '??' && !this.alreadyFocused && this.doAutoFocus) {
299
-    this.alreadyFocused = true;
300
-    this.doAutoFocus = false;
301
-
302
-    elem = elem.find('.editable');
303
-    elem.click();
304
-    elem.find('input').focus().select();
305
-    //still missing a proper scrolling into the selected input
306
-  }
307
-
308
-  if (changed) this.fireChange();
309
-};
310
-
311
-JSONEditor.prototype.haveThingsChanged = function() {
312
-  return (this.json && JSON.stringify(this.json, null, 2) != this.wrapped.get(0).value);
313
-}
314
-
315
-JSONEditor.prototype.saveScrollPosition = function() {
316
-  this.oldScrollHeight = this.builder.scrollTop();
317
-};
318
-
319
-JSONEditor.prototype.recoverScrollPosition = function() {
320
-  this.builder.scrollTop(this.oldScrollHeight);
321
-};
322
-
323
-JSONEditor.prototype.setJsonFromText = function() {
324
-  if (this.wrapped.get(0).value.length == 0) this.wrapped.get(0).value = "{}";
325
-  try {
326
-    this.wrapped.get(0).value = this.wrapped.get(0).value.replace(/((^|[^\\])(\\\\)*)\\n/g, '$1\\\\n').replace(/((^|[^\\])(\\\\)*)\\t/g, '$1\\\\t');
327
-    this.json = JSON.parse(this.wrapped.get(0).value);
328
-  } catch(e) {
329
-    alert("Got bad JSON from text.");
330
-  }
331
-};
332
-
333
-JSONEditor.prototype.checkJsonInText = function() {
334
-  try {
335
-    JSON.parse(this.wrapped.get(0).value);
336
-    return true;
337
-  } catch(e) {
338
-    return false;
339
-  }
340
-};
341
-
342
-JSONEditor.prototype.logJSON = function() {
343
-  console.log(JSON.stringify(this.json, null, 2));
344
-};
345
-
346
-JSONEditor.prototype.cleanBuilder = function() {
347
-  if (!this.builder) {
348
-    this.builder = $('<div class="builder"></div>');
349
-    this.container.append(this.builder);
350
-  }
351
-  this.saveScrollPosition();
352
-  this.builder.text('');
353
-
354
-  this.builder.css("position", "absolute").css("top", 0).css("left", 0);
355
-  this.builder.width(this.wrapped.width()).height(this.wrapped.height());
356
-  this.wrapped.css("position", "absolute").css("top", 0).css("left", 0);
357
-  this.showFunctionButtons("defined");
358
-};
359
-
360
-JSONEditor.prototype.updateStruct = function(struct, key, val, kind, selectionStart, selectionEnd) {
361
-  if(kind == 'key') {
362
-    if (selectionStart && selectionEnd) val = key.substring(0, selectionStart) + val + key.substring(selectionEnd, key.length);
363
-    struct[val] = struct[key];
364
-
365
-    		//order keys
366
-		var orderrest = 0;
367
-		$.each(struct, function (index, value) {
368
-			//re set rest of the keys
369
-			if(orderrest & index != val) {
370
-				var tempval = struct[index];
371
-				delete struct[index];
372
-				struct[index] = tempval;
373
-			}
374
-			if(key == index) {
375
-				orderrest = 1;
376
-			}
377
-		});
378
-		// end of order keys
379
-
380
-    if (key != val) delete struct[key];
381
-  } else {
382
-    if (selectionStart && selectionEnd) val = struct[key].substring(0, selectionStart) + val + struct[key].substring(selectionEnd, struct[key].length);
383
-    struct[key] = val;
384
-  }
385
-};
386
-
387
-JSONEditor.prototype.getValFromStruct = function(struct, key, kind) {
388
-  if(kind == 'key') {
389
-    return key;
390
-  } else {
391
-    return struct[key];
392
-  }
393
-};
394
-
395
-JSONEditor.prototype.doTruncation = function(trueOrFalse) {
396
-  if (this._doTruncation != trueOrFalse) {
397
-    this._doTruncation = trueOrFalse;
398
-    this.rebuild();
399
-  }
400
-};
401
-
402
-JSONEditor.prototype.showWipe = function(trueOrFalse) {
403
-  if (this._showWipe != trueOrFalse) {
404
-    this._showWipe = trueOrFalse;
405
-    this.rebuild();
406
-  }
407
-};
408
-
409
-JSONEditor.prototype.truncate = function(text, length) {
410
-  if (text.length == 0) return '-empty-';
411
-  if(this._doTruncation && text.length > (length || 30)) return(text.substring(0, (length || 30)) + '...');
412
-  return text;
413
-};
414
-
415
-JSONEditor.prototype.replaceLastSelectedFieldIfRecent = function(text) {
416
-  if (this.lastEditingUnfocusedTime > (new Date()).getTime() - 200) { // Short delay for unfocus to occur.
417
-    this.setLastEditingFocus(text);
418
-    this.rebuild();
419
-  }
420
-};
421
-
422
-JSONEditor.prototype.editingUnfocused = function(elem, struct, key, root, kind) {
423
-  var self = this;
424
-
425
-  var selectionStart = elem && elem.target.selectionStart;
426
-  var selectionEnd = elem && elem.target.selectionEnd;
427
-
428
-  this.setLastEditingFocus = function(text) {
429
-    self.updateStruct(struct, key, text, kind, selectionStart, selectionEnd);
430
-    self.json = root; // Because self.json is a new reference due to rebuild.
431
-  };
432
-  this.lastEditingUnfocusedTime = (new Date()).getTime();
433
-};
434
-
435
-JSONEditor.prototype.edit = function(e, key, struct, root, kind){
436
-  var self = this;
437
-  var form = $("<form></form>").css('display', 'inline');
438
-  var input = document.createElement("INPUT");
439
-  input.value = this.getValFromStruct(struct, key, kind);
440
-  //alert(this.getValFromStruct(struct, key, kind));
441
-  input.className = 'edit_field';
442
-  var onblur = function(elem) {
443
-    var val = input.value;
444
-    self.updateStruct(struct, key, val, kind);
445
-    self.editingUnfocused(elem, struct, (kind == 'key' ? val : key), root, kind);
446
-    e.text(self.truncate(val));
447
-    e.get(0).editing = false;
448
-    if (key != val) self.rebuild();
449
-    return false;
450
-  };
451
-  $(input).blur(onblur);
452
-  $(input).keydown(function(e) {
453
-    if (e.keyCode == 9 || e.keyCode == 13) { // Tab and enter
454
-      self.doAutoFocus = true;
455
-      onblur(e);
456
-      return false;
457
-    }
458
-  });
459
-  $(form).submit(function(e) { self.doAutoFocus = true; onblur(e); return false;}).append(input);
460
-  $(e).html(form);
461
-  input.focus();
462
-};
463
-
464
-JSONEditor.prototype.editable = function(text, key, struct, root, kind) {
465
-  var self = this;
466
-  var elem = $('<span class="editable" href="#"></span>').text(this.truncate(text)).click(function(e) {
467
-    if (!this.editing) {
468
-      this.editing = true;
469
-      self.edit($(this), key, struct, root, kind);
470
-    }
471
-    return true;
472
-  });
473
-
474
-  return elem;
475
-}
476
-
477
-JSONEditor.prototype.build = function(json, node, parent, key, root) {
478
-  var elem = null;
479
-  if(json instanceof Array){
480
-    var bq = $(document.createElement("BLOCKQUOTE"));
481
-    bq.append($('<div class="brackets">[</div>'));
482
-
483
-    bq.prepend(this.addUI(json));
484
-    if (parent) {
485
-      if (this._showWipe) bq.prepend(this.wipeUI(key, parent));
486
-    	bq.prepend(this.deleteUI(key, parent));
487
-    }
341
+    };
488 342
 
489
-    for(var i = 0; i < json.length; i++) {
490
-      var innerbq = $(document.createElement("BLOCKQUOTE"));
491
-      var newElem = this.build(json[i], innerbq, json, i, root);
492
-      if (newElem) if (newElem.text() == "??") elem = newElem;
493
-      bq.append(innerbq);
494
-    }
343
+    JSONEditor.prototype.logJSON = function() {
344
+      return console.log(JSON.stringify(this.json, null, 2));
345
+    };
495 346
 
496
-    bq.append($('<div class="brackets">]</div>'));
497
-    node.append(bq);
498
-  } else if (json instanceof Object) {
499
-    var bq = $(document.createElement("BLOCKQUOTE"));
500
-    bq.append($('<div class="bracers">{</div>'));
501
-
502
-    for(var i in json){
503
-      var innerbq = $(document.createElement("BLOCKQUOTE"));
504
-      var newElem = this.editable(i.toString(), i.toString(), json, root, 'key').wrap('<span class="key"></span>').parent();
505
-      innerbq.append(newElem);
506
-      if (newElem) if (newElem.text() == "??") elem = newElem;
507
-      if (typeof json[i] != 'string' && typeof json[i] != 'number') {
508
-        innerbq.prepend(this.braceUI(i, json));
509
-        innerbq.prepend(this.bracketUI(i, json));
510
-        if (this._showWipe) innerbq.prepend(this.wipeUI(i, json));
511
-        innerbq.prepend(this.deleteUI(i, json, true));
347
+    JSONEditor.prototype.cleanBuilder = function() {
348
+      if (!this.builder) {
349
+        this.builder = $('<div class="builder"></div>');
350
+        this.container.append(this.builder);
512 351
       }
513
-      innerbq.append($('<span class="colon">: </span>'));
514
-      newElem = this.build(json[i], innerbq, json, i, root);
515
-      if (newElem) if (newElem.text() == "??") elem = newElem;
516
-      bq.append(innerbq);
517
-    }
352
+      this.saveScrollPosition();
353
+      this.builder.text('');
354
+      return this.showFunctionButtons("defined");
355
+    };
356
+
357
+    JSONEditor.prototype.updateStruct = function(struct, key, val, kind, selectionStart, selectionEnd) {
358
+      var orderrest;
359
+      if (kind === 'key') {
360
+        if (selectionStart && selectionEnd) {
361
+          val = key.substring(0, selectionStart) + val + key.substring(selectionEnd, key.length);
362
+        }
363
+        struct[val] = struct[key];
364
+        orderrest = 0;
365
+        $.each(struct, function(index, value) {
366
+          var tempval;
367
+          if (orderrest & index !== val) {
368
+            tempval = struct[index];
369
+            delete struct[index];
370
+            struct[index] = tempval;
371
+          }
372
+          if (key === index) {
373
+            return orderrest = 1;
374
+          }
375
+        });
376
+        if (key !== val) {
377
+          return delete struct[key];
378
+        }
379
+      } else {
380
+        if (selectionStart && selectionEnd) {
381
+          val = struct[key].substring(0, selectionStart) + val + struct[key].substring(selectionEnd, struct[key].length);
382
+        }
383
+        return struct[key] = val;
384
+      }
385
+    };
518 386
 
519
-    bq.prepend(this.addUI(json));
520
-    if (parent) {
521
-      if (this._showWipe) bq.prepend(this.wipeUI(key, parent));
522
-    	bq.prepend(this.deleteUI(key, parent));
523
-    }
387
+    JSONEditor.prototype.getValFromStruct = function(struct, key, kind) {
388
+      if (kind === 'key') {
389
+        return key;
390
+      } else {
391
+        return struct[key];
392
+      }
393
+    };
524 394
 
525
-    bq.append($('<div class="bracers">}</div>'));
526
-    node.append(bq);
527
-  } else {
528
-    elem = this.editable(json.toString(), key, parent, root, 'value').wrap('<span class="val"></span>').parent();
529
-    node.append(elem);
530
-    node.prepend(this.braceUI(key, parent));
531
-    node.prepend(this.bracketUI(key, parent));
532
-    if (parent) {
533
-      if (this._showWipe) node.prepend(this.wipeUI(key, parent));
534
-    	node.prepend(this.deleteUI(key, parent));
535
-    }
395
+    JSONEditor.prototype.doTruncation = function(trueOrFalse) {
396
+      if (this._doTruncation !== trueOrFalse) {
397
+        this._doTruncation = trueOrFalse;
398
+        return this.rebuild();
399
+      }
400
+    };
401
+
402
+    JSONEditor.prototype.showWipe = function(trueOrFalse) {
403
+      if (this._showWipe !== trueOrFalse) {
404
+        this._showWipe = trueOrFalse;
405
+        return this.rebuild();
406
+      }
407
+    };
408
+
409
+    JSONEditor.prototype.truncate = function(text, length) {
410
+      if (text.length === 0) {
411
+        return '-empty-';
412
+      }
413
+      if (this._doTruncation && text.length > (length || 30)) {
414
+        return text.substring(0, length || 30) + '...';
415
+      }
416
+      return text;
417
+    };
418
+
419
+    JSONEditor.prototype.replaceLastSelectedFieldIfRecent = function(text) {
420
+      if (this.lastEditingUnfocusedTime > (new Date()).getTime() - 200) {
421
+        this.setLastEditingFocus(text);
422
+        return this.rebuild();
423
+      }
424
+    };
425
+
426
+    JSONEditor.prototype.editingUnfocused = function(elem, struct, key, root, kind) {
427
+      var selectionEnd, selectionStart,
428
+        _this = this;
429
+      selectionStart = elem != null ? elem.selectionStart : void 0;
430
+      selectionEnd = elem != null ? elem.selectionEnd : void 0;
431
+      this.setLastEditingFocus = function(text) {
432
+        _this.updateStruct(struct, key, text, kind, selectionStart, selectionEnd);
433
+        return _this.json = root;
434
+      };
435
+      return this.lastEditingUnfocusedTime = (new Date()).getTime();
436
+    };
437
+
438
+    JSONEditor.prototype.edit = function($elem, key, struct, root, kind) {
439
+      var $input, blurHandler, form,
440
+        _this = this;
441
+      form = $("<form></form>").css('display', 'inline');
442
+      $input = $("<input />");
443
+      $input.val(this.getValFromStruct(struct, key, kind));
444
+      $input.addClass('edit_field');
445
+      blurHandler = function() {
446
+        var val, _ref;
447
+        val = $input.val();
448
+        _this.updateStruct(struct, key, val, kind);
449
+        _this.editingUnfocused($elem, struct, (_ref = kind === 'key') != null ? _ref : {
450
+          val: key
451
+        }, root, kind);
452
+        $elem.text(_this.truncate(val));
453
+        $elem.get(0).editing = false;
454
+        if (key !== val) {
455
+          return _this.rebuild();
456
+        }
457
+      };
458
+      $input.blur(blurHandler);
459
+      $input.keydown(function(e) {
460
+        if (e.keyCode === 9 || e.keyCode === 13) {
461
+          _this.doAutoFocus = true;
462
+          return blurHandler();
463
+        }
464
+      });
465
+      $(form).append($input).submit(function(e) {
466
+        e.preventDefault();
467
+        _this.doAutoFocus = true;
468
+        return blurHandler();
469
+      });
470
+      $elem.html(form);
471
+      return $input.focus();
472
+    };
473
+
474
+    JSONEditor.prototype.editable = function(text, key, struct, root, kind) {
475
+      var elem, self;
476
+      self = this;
477
+      elem = $('<span class="editable" href="#"></span>').text(this.truncate(text)).click(function(e) {
478
+        if (!this.editing) {
479
+          this.editing = true;
480
+          self.edit($(this), key, struct, root, kind);
481
+        }
482
+        return true;
483
+      });
484
+      return elem;
485
+    };
486
+
487
+    JSONEditor.prototype.build = function(json, node, parent, key, root) {
488
+      var bq, elem, i, innerbq, jsonkey, jsonvalue, newElem, _i, _ref;
489
+      elem = null;
490
+      if (json instanceof Array) {
491
+        bq = $(document.createElement("BLOCKQUOTE"));
492
+        bq.append($('<div class="brackets">[</div>'));
493
+        bq.prepend(this.addUI(json));
494
+        if (parent) {
495
+          if (this._showWipe) {
496
+            bq.prepend(this.wipeUI(key, parent));
497
+          }
498
+          bq.prepend(this.deleteUI(key, parent));
499
+        }
500
+        for (i = _i = 0, _ref = json.length; 0 <= _ref ? _i < _ref : _i > _ref; i = 0 <= _ref ? ++_i : --_i) {
501
+          innerbq = $(document.createElement("BLOCKQUOTE"));
502
+          newElem = this.build(json[i], innerbq, json, i, root);
503
+          if (newElem && newElem.text() === "??") {
504
+            elem = newElem;
505
+          }
506
+          bq.append(innerbq);
507
+        }
508
+        bq.append($('<div class="brackets">]</div>'));
509
+        node.append(bq);
510
+      } else if (json instanceof Object) {
511
+        bq = $(document.createElement("BLOCKQUOTE"));
512
+        bq.append($('<div class="bracers">{</div>'));
513
+        for (jsonkey in json) {
514
+          jsonvalue = json[jsonkey];
515
+          innerbq = $(document.createElement("BLOCKQUOTE"));
516
+          newElem = this.editable(jsonkey.toString(), jsonkey.toString(), json, root, 'key').wrap('<span class="key"></b>').parent();
517
+          innerbq.append(newElem);
518
+          if (newElem && newElem.text() === "??") {
519
+            elem = newElem;
520
+          }
521
+          if (typeof jsonvalue !== 'string') {
522
+            innerbq.prepend(this.braceUI(jsonkey, json));
523
+            innerbq.prepend(this.bracketUI(jsonkey, json));
524
+            if (this._showWipe) {
525
+              innerbq.prepend(this.wipeUI(jsonkey, json));
526
+            }
527
+            innerbq.prepend(this.deleteUI(jsonkey, json, true));
528
+          }
529
+          innerbq.append($('<span class="colon">: </span>'));
530
+          newElem = this.build(jsonvalue, innerbq, json, jsonkey, root);
531
+          if (newElem && newElem.text() === "??") {
532
+            elem = newElem;
533
+          }
534
+          bq.append(innerbq);
535
+        }
536
+        bq.prepend(this.addUI(json));
537
+        if (parent) {
538
+          if (this._showWipe) {
539
+            bq.prepend(this.wipeUI(key, parent));
540
+          }
541
+          bq.prepend(this.deleteUI(key, parent));
542
+        }
543
+        bq.append($('<div class="bracers">}</div>'));
544
+        node.append(bq);
545
+      } else {
546
+        elem = this.editable(json.toString(), key, parent, root, 'value').wrap('<span class="val"></span>').parent();
547
+        node.append(elem);
548
+        node.prepend(this.braceUI(key, parent));
549
+        node.prepend(this.bracketUI(key, parent));
550
+        if (parent) {
551
+          if (this._showWipe) {
552
+            node.prepend(this.wipeUI(key, parent));
553
+          }
554
+          node.prepend(this.deleteUI(key, parent));
555
+        }
556
+      }
557
+      return elem;
558
+    };
559
+
560
+    return JSONEditor;
561
+
562
+  })();
536 563
 
537
-  }
538
-  return elem;
539
-};
564
+}).call(this);

+ 37 - 0
vendor/assets/stylesheets/jquery.json-editor.css

@@ -0,0 +1,37 @@
1
+.json-editor {
2
+  background-color: #FFF;
3
+  position: relative; }
4
+  .json-editor textarea {
5
+    width: 100%;
6
+    font-family: monospace; }
7
+  .json-editor .builder {
8
+    background-color: white;
9
+    overflow: auto;
10
+    font-size: 0.9em; }
11
+    .json-editor .builder .key {
12
+      font-weight: bold; }
13
+      .json-editor .builder .key .edit_field {
14
+        width: 150px; }
15
+    .json-editor .builder .val .edit_field {
16
+      width: 200px; }
17
+  .json-editor blockquote {
18
+    margin: 0;
19
+    padding: 0;
20
+    clear: both;
21
+    padding-left: 7px; }
22
+  .json-editor div {
23
+    background-color: #cfc;
24
+    margin: 1px;
25
+    padding: 2px; }
26
+  .json-editor .val {
27
+    font-style: italic; }
28
+  .json-editor .key a, .json-editor .val a {
29
+    color: black;
30
+    text-decoration: none; }
31
+  .json-editor .icon {
32
+    display: block;
33
+    float: right;
34
+    text-decoration: none;
35
+    padding-left: 5px;
36
+    border: 0;
37
+    color: blue; }

+ 0 - 63
vendor/assets/stylesheets/jquery.json-editor.css.scss

@@ -1,63 +0,0 @@
1
-.json-editor {
2
-  background-color: #FFF;
3
-  position: relative;
4
-
5
-  .builder {
6
-    background-color: white;
7
-    overflow: auto;
8
-    font-size: 0.9em;
9
-    padding-right: 10px;
10
-
11
-    .key {
12
-      font-weight: bold;
13
-
14
-      .edit_field {
15
-        width: 80px;
16
-      }
17
-
18
-      a {
19
-        color: black;
20
-        text-decoration: none;
21
-      }
22
-    }
23
-
24
-    .val {
25
-      font-style: italic;
26
-
27
-      .edit_field {
28
-        width: 180px;
29
-      }
30
-
31
-      a {
32
-        color: black;
33
-        text-decoration: none;
34
-      }
35
-    }
36
-  }
37
-
38
-  blockquote {
39
-    margin: 0;
40
-    padding: 0;
41
-    clear: both;
42
-    padding-left: 7px;
43
-  }
44
-
45
-  div {
46
-    background-color: #cfc;
47
-    margin: 1px;
48
-    padding: 2px;
49
-  }
50
-
51
-  .icon {
52
-    display: block;
53
-    float: right;
54
-    text-decoration: none;
55
-    padding: 0 5px;
56
-    border: 0 !important;
57
-    color: blue;
58
-
59
-    &:hover {
60
-      background-color: #bbb;
61
-    }
62
-  }
63
-}